From 6d03bc3835cbafa23010da623bc4f07086f3f87b Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Tue, 9 Jan 2024 10:02:31 -0500 Subject: [PATCH] Add audio Speaker (#2795) * Add Backend model/repo/controller for speaker * Add audio consent api; Add speaker to project state * Add Speaker types to Startup.cs * Add skeleton setting for project speakers * Enable add/edit/delete speaker * Add consent image upload interface * Enable hearing/seeing speaker consent * Add speaker menu * Consolidate consent icon * Add Pronunciation model for audio * Export audio speaker in pronunciation label * Protect audio with English label to prevent overwriting * Implement FileWithSpeakerId extends File * Enable changing audio speaker * Include consent files in export * Add py script for db update * Remove old one-shot script * Update db script: protect audio; add execute permission --- .../Controllers/AudioControllerTests.cs | 101 +- .../Controllers/LiftControllerTests.cs | 20 +- .../Controllers/SpeakerControllerTests.cs | 326 ++++ Backend.Tests/Mocks/SpeakerRepositoryMock.cs | 81 + Backend.Tests/Models/SpeakerTests.cs | 49 + Backend.Tests/Models/WordTests.cs | 37 +- Backend.Tests/Services/LiftServiceTests.cs | 4 +- Backend.Tests/Services/WordServiceTests.cs | 33 + Backend.Tests/Util.cs | 2 +- Backend/Contexts/SpeakerContext.cs | 22 + Backend/Controllers/AudioController.cs | 21 +- Backend/Controllers/SpeakerController.cs | 297 ++++ Backend/Helper/FileStorage.cs | 37 +- Backend/Interfaces/ISpeakerContext.cs | 10 + Backend/Interfaces/ISpeakerRepository.cs | 17 + Backend/Models/Speaker.cs | 74 + Backend/Models/Word.cs | 73 +- Backend/Repositories/SpeakerRepository.cs | 92 ++ Backend/Services/LiftService.cs | 81 +- Backend/Services/MergeService.cs | 1 - Backend/Services/WordService.cs | 12 +- Backend/Startup.cs | 26 +- docs/user_guide/docs/dataEntry.md | 16 +- docs/user_guide/docs/project.md | 28 + maintenance/scripts/db_update_audio_type.py | 59 + .../scripts/db_update_sem_dom_in_senses.py | 146 -- public/locales/en/translation.json | 32 +- scripts/generate_openapi.py | 1 + src/api/.openapi-generator/FILES | 4 + src/api/api.ts | 1 + src/api/api/audio-api.ts | 228 +++ src/api/api/speaker-api.ts | 1317 +++++++++++++++++ src/api/models/consent-type.ts | 24 + src/api/models/index.ts | 3 + src/api/models/pronunciation.ts | 39 + src/api/models/speaker.ts | 47 + src/api/models/word.ts | 5 +- src/backend/index.ts | 110 +- src/components/AppBar/ProjectButtons.tsx | 11 +- src/components/AppBar/SpeakerMenu.tsx | 138 ++ .../AppBar/tests/ProjectButtons.test.tsx | 45 +- .../AppBar/tests/SpeakerMenu.test.tsx | 101 ++ .../DataEntryTable/NewEntry/index.tsx | 24 +- .../NewEntry/tests/index.test.tsx | 8 +- .../DataEntry/DataEntryTable/RecentEntry.tsx | 22 +- .../DataEntry/DataEntryTable/index.tsx | 109 +- .../DataEntryTable/tests/RecentEntry.test.tsx | 19 +- .../DataEntryTable/tests/index.test.tsx | 1 - src/components/Dialogs/RecordAudioDialog.tsx | 37 + src/components/Dialogs/SubmitTextDialog.tsx | 110 ++ src/components/Dialogs/UploadImageDialog.tsx | 9 +- src/components/Dialogs/ViewImageDialog.tsx | 47 + src/components/Dialogs/index.ts | 6 + src/components/Project/ProjectActions.ts | 11 +- src/components/Project/ProjectReducer.ts | 12 +- src/components/Project/ProjectReduxTypes.ts | 3 +- .../Project/tests/ProjectActions.test.tsx | 33 +- src/components/ProjectSettings/index.tsx | 12 + .../ProjectSettings/tests/SettingsTabTypes.ts | 7 +- .../ProjectSettings/tests/index.test.tsx | 1 + .../ProjectUsers/ProjectSpeakersList.tsx | 158 ++ .../SpeakerConsentListItemIcon.tsx | 187 +++ .../tests/ProjectSpeakersList.test.tsx | 49 + .../tests/SpeakerConsentListItemIcon.test.tsx | 130 ++ src/components/Pronunciations/AudioPlayer.tsx | 152 +- .../Pronunciations/AudioRecorder.tsx | 24 +- .../Pronunciations/PronunciationsBackend.tsx | 36 +- .../Pronunciations/PronunciationsFrontend.tsx | 35 +- .../Pronunciations/RecorderIcon.tsx | 6 +- .../tests/AudioRecorder.test.tsx | 22 +- .../tests/PronunciationsBackend.test.tsx | 16 +- .../tests/PronunciationsFrontend.test.tsx | 16 +- src/components/Pronunciations/utilities.ts | 19 +- src/components/UserSettings/AvatarUpload.tsx | 77 - src/components/WordCard/index.tsx | 7 +- src/components/WordCard/tests/index.test.tsx | 5 +- .../Redux/ReviewEntriesActions.ts | 53 +- .../Redux/tests/ReviewEntriesActions.test.tsx | 113 +- .../ReviewEntriesTable/CellColumns.tsx | 47 +- .../CellComponents/PronunciationsCell.tsx | 34 +- .../tests/PronunciationsCell.test.tsx | 43 +- src/goals/ReviewEntries/ReviewEntriesTypes.ts | 5 +- src/goals/ReviewEntries/tests/index.test.tsx | 2 - src/setupTests.js | 6 + src/types/project.ts | 11 +- src/types/word.ts | 29 + 86 files changed, 4902 insertions(+), 622 deletions(-) create mode 100644 Backend.Tests/Controllers/SpeakerControllerTests.cs create mode 100644 Backend.Tests/Mocks/SpeakerRepositoryMock.cs create mode 100644 Backend.Tests/Models/SpeakerTests.cs create mode 100644 Backend/Contexts/SpeakerContext.cs create mode 100644 Backend/Controllers/SpeakerController.cs create mode 100644 Backend/Interfaces/ISpeakerContext.cs create mode 100644 Backend/Interfaces/ISpeakerRepository.cs create mode 100644 Backend/Models/Speaker.cs create mode 100644 Backend/Repositories/SpeakerRepository.cs create mode 100755 maintenance/scripts/db_update_audio_type.py delete mode 100755 maintenance/scripts/db_update_sem_dom_in_senses.py create mode 100644 src/api/api/speaker-api.ts create mode 100644 src/api/models/consent-type.ts create mode 100644 src/api/models/pronunciation.ts create mode 100644 src/api/models/speaker.ts create mode 100644 src/components/AppBar/SpeakerMenu.tsx create mode 100644 src/components/AppBar/tests/SpeakerMenu.test.tsx create mode 100644 src/components/Dialogs/RecordAudioDialog.tsx create mode 100644 src/components/Dialogs/SubmitTextDialog.tsx create mode 100644 src/components/Dialogs/ViewImageDialog.tsx create mode 100644 src/components/ProjectUsers/ProjectSpeakersList.tsx create mode 100644 src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx create mode 100644 src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx create mode 100644 src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx delete mode 100644 src/components/UserSettings/AvatarUpload.tsx diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 7801e33e54..3aca57c461 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -34,6 +34,10 @@ protected virtual void Dispose(bool disposing) } private string _projId = null!; + private string _wordId = null!; + private const string FileName = "sound.mp3"; // file in Backend.Tests/Assets/ + private readonly Stream _stream = File.OpenRead(Path.Combine(Util.AssetsDir, FileName)); + private FileUpload _fileUpload = null!; [SetUp] public void Setup() @@ -45,65 +49,104 @@ public void Setup() _audioController = new AudioController(_wordRepo, _wordService, _permissionService); _projId = _projRepo.Create(new Project { Name = "AudioControllerTests" }).Result!.Id; + _wordId = _wordRepo.Create(Util.RandomWord(_projId)).Result.Id; + + var formFile = new FormFile(_stream, 0, _stream.Length, "Name", FileName); + _fileUpload = new FileUpload { File = formFile, Name = "FileName" }; } - [TearDown] - public void TearDown() + [Test] + public void TestUploadAudioFileUnauthorized() { - _projRepo.Delete(_projId); + _audioController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _audioController.UploadAudioFile(_projId, _wordId, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + + result = _audioController.UploadAudioFile(_projId, _wordId, "", _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); } [Test] - public void TestDownloadAudioFileInvalidArguments() + public void TestUploadAudioFileInvalidArguments() { - var result = _audioController.DownloadAudioFile("invalid/projId", "wordId", "fileName"); + var result = _audioController.UploadAudioFile("invalid/projId", _wordId, _fileUpload).Result; Assert.That(result, Is.TypeOf()); - result = _audioController.DownloadAudioFile("projId", "invalid/wordId", "fileName"); + result = _audioController.UploadAudioFile(_projId, "invalid/wordId", _fileUpload).Result; Assert.That(result, Is.TypeOf()); - result = _audioController.DownloadAudioFile("projId", "wordId", "invalid/fileName"); + result = _audioController.UploadAudioFile("invalid/projId", _wordId, "speakerId", _fileUpload).Result; + Assert.That(result, Is.TypeOf()); + + result = _audioController.UploadAudioFile(_projId, "invalid/wordId", "speakerId", _fileUpload).Result; Assert.That(result, Is.TypeOf()); } [Test] - public void TestDownloadAudioFileNoFile() + public void TestUploadConsentNullFile() { - var result = _audioController.DownloadAudioFile("projId", "wordId", "fileName"); - Assert.That(result, Is.TypeOf()); + var result = _audioController.UploadAudioFile(_projId, _wordId, new()).Result; + Assert.That(result, Is.InstanceOf()); + + result = _audioController.UploadAudioFile(_projId, _wordId, "speakerId", new()).Result; + Assert.That(result, Is.InstanceOf()); } [Test] - public void TestAudioImport() + public void TestUploadConsentEmptyFile() { - const string soundFileName = "sound.mp3"; - var filePath = Path.Combine(Util.AssetsDir, soundFileName); + // Use 0 for the third argument + var formFile = new FormFile(_stream, 0, 0, "Name", FileName); + _fileUpload = new FileUpload { File = formFile, Name = FileName }; + + var result = _audioController.UploadAudioFile(_projId, _wordId, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + result = _audioController.UploadAudioFile(_projId, _wordId, "speakerId", _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + } - // Open the file to read to controller. - using var stream = File.OpenRead(filePath); - var formFile = new FormFile(stream, 0, stream.Length, "name", soundFileName); - var fileUpload = new FileUpload { File = formFile, Name = "FileName" }; + [Test] + public void TestUploadAudioFile() + { + // `_fileUpload` contains the file stream and the name of the file. + _ = _audioController.UploadAudioFile(_projId, _wordId, "speakerId", _fileUpload).Result; - var word = _wordRepo.Create(Util.RandomWord(_projId)).Result; + var foundWord = _wordRepo.GetWord(_projId, _wordId).Result; + Assert.That(foundWord?.Audio, Is.Not.Null); + } - // `fileUpload` contains the file stream and the name of the file. - _ = _audioController.UploadAudioFile(_projId, word.Id, fileUpload).Result; + [Test] + public void TestDownloadAudioFileInvalidArguments() + { + var result = _audioController.DownloadAudioFile("invalid/projId", "wordId", "fileName"); + Assert.That(result, Is.TypeOf()); - var foundWord = _wordRepo.GetWord(_projId, word.Id).Result; - Assert.That(foundWord?.Audio, Is.Not.Null); + result = _audioController.DownloadAudioFile("projId", "invalid/wordId", "fileName"); + Assert.That(result, Is.TypeOf()); + + result = _audioController.DownloadAudioFile("projId", "wordId", "invalid/fileName"); + Assert.That(result, Is.TypeOf()); } [Test] - public void DeleteAudio() + public void TestDownloadAudioFileNoFile() { - // Fill test database - var origWord = _wordRepo.Create(Util.RandomWord(_projId)).Result; + var result = _audioController.DownloadAudioFile("projId", "wordId", "fileName"); + Assert.That(result, Is.TypeOf()); + } - // Add audio file to word - origWord.Audio.Add("a.wav"); + [Test] + public void DeleteAudio() + { + // Refill test database + _wordRepo.DeleteAllWords(_projId); + var origWord = Util.RandomWord(_projId); + var fileName = "a.wav"; + origWord.Audio.Add(new Pronunciation(fileName)); + var wordId = _wordRepo.Create(origWord).Result.Id; // Test delete function - _ = _audioController.DeleteAudioFile(_projId, origWord.Id, "a.wav").Result; + _ = _audioController.DeleteAudioFile(_projId, wordId, fileName).Result; // Original word persists Assert.That(_wordRepo.GetAllWords(_projId).Result, Has.Count.EqualTo(2)); @@ -119,7 +162,7 @@ public void DeleteAudio() // Ensure the word with deleted audio is in the frontier Assert.That(frontier, Has.Count.EqualTo(1)); - Assert.That(frontier[0].Id, Is.Not.EqualTo(origWord.Id)); + Assert.That(frontier[0].Id, Is.Not.EqualTo(wordId)); Assert.That(frontier[0].Audio, Has.Count.EqualTo(0)); Assert.That(frontier[0].History, Has.Count.EqualTo(1)); } diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 6f812c9ffd..f5ceda8534 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -23,6 +23,7 @@ public class LiftControllerTests : IDisposable { private IProjectRepository _projRepo = null!; private ISemanticDomainRepository _semDomRepo = null!; + private ISpeakerRepository _speakerRepo = null!; private IWordRepository _wordRepo = null!; private ILiftService _liftService = null!; private IHubContext _notifyService = null!; @@ -54,8 +55,9 @@ public void Setup() { _projRepo = new ProjectRepositoryMock(); _semDomRepo = new SemanticDomainRepositoryMock(); + _speakerRepo = new SpeakerRepositoryMock(); _wordRepo = new WordRepositoryMock(); - _liftService = new LiftService(_semDomRepo); + _liftService = new LiftService(_semDomRepo, _speakerRepo); _notifyService = new HubContextMock(); _permissionService = new PermissionServiceMock(); _wordService = new WordService(_wordRepo); @@ -512,17 +514,18 @@ public void TestRoundtrip(RoundTripObj roundTripObj) // We are currently only testing guids and imported audio on the single-entry data sets. if (allWords.Count == 1) { + var word = allWords[0].Clone(); Assert.That(roundTripObj.EntryGuid, Is.Not.EqualTo("")); - Assert.That(allWords[0].Guid.ToString(), Is.EqualTo(roundTripObj.EntryGuid)); + Assert.That(word.Guid.ToString(), Is.EqualTo(roundTripObj.EntryGuid)); if (roundTripObj.SenseGuid != "") { - Assert.That(allWords[0].Senses[0].Guid.ToString(), Is.EqualTo(roundTripObj.SenseGuid)); + Assert.That(word.Senses[0].Guid.ToString(), Is.EqualTo(roundTripObj.SenseGuid)); } - foreach (var audioFile in allWords[0].Audio) + foreach (var audio in word.Audio) { - Assert.That(roundTripObj.AudioFiles, Does.Contain(Path.GetFileName(audioFile))); + Assert.That(roundTripObj.AudioFiles, Does.Contain(Path.GetFileName(audio.FileName))); + Assert.That(audio.Protected, Is.True); } - } // Assert that the first SemanticDomain doesn't have an empty MongoId. @@ -588,10 +591,11 @@ public void TestRoundtrip(RoundTripObj roundTripObj) // We are currently only testing guids on the single-entry data sets. if (roundTripObj.EntryGuid != "" && allWords.Count == 1) { - Assert.That(allWords[0].Guid.ToString(), Is.EqualTo(roundTripObj.EntryGuid)); + var word = allWords[0]; + Assert.That(word.Guid.ToString(), Is.EqualTo(roundTripObj.EntryGuid)); if (roundTripObj.SenseGuid != "") { - Assert.That(allWords[0].Senses[0].Guid.ToString(), Is.EqualTo(roundTripObj.SenseGuid)); + Assert.That(word.Senses[0].Guid.ToString(), Is.EqualTo(roundTripObj.SenseGuid)); } } diff --git a/Backend.Tests/Controllers/SpeakerControllerTests.cs b/Backend.Tests/Controllers/SpeakerControllerTests.cs new file mode 100644 index 0000000000..a53218b4b2 --- /dev/null +++ b/Backend.Tests/Controllers/SpeakerControllerTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Backend.Tests.Mocks; +using BackendFramework.Controllers; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; + +namespace Backend.Tests.Controllers +{ + public class SpeakerControllerTests : IDisposable + { + private ISpeakerRepository _speakerRepo = null!; + private PermissionServiceMock _permissionService = null!; + private SpeakerController _speakerController = null!; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _speakerController?.Dispose(); + } + } + + private const string ProjId = "proj-id"; + private const string Name = "Madam Name"; + private const string FileName = "sound.mp3"; // file in Backend.Tests/Assets/ + private Speaker _speaker = null!; + private readonly Stream _stream = File.OpenRead(Path.Combine(Util.AssetsDir, FileName)); + private FormFile _formFile = null!; + private FileUpload _fileUpload = null!; + + [SetUp] + public void Setup() + { + _speakerRepo = new SpeakerRepositoryMock(); + _permissionService = new PermissionServiceMock(); + _speakerController = new SpeakerController(_speakerRepo, _permissionService); + + _speaker = _speakerRepo.Create(new Speaker { Name = Name, ProjectId = ProjId }).Result; + + _formFile = new FormFile(_stream, 0, _stream.Length, "name", FileName) + { + Headers = new HeaderDictionary(), + ContentType = "audio" + }; + _fileUpload = new FileUpload { File = _formFile, Name = FileName }; + } + + [Test] + public void TestGetProjectSpeakersUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.GetProjectSpeakers(ProjId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestGetProjectSpeakersProjectSpeakers() + { + _ = _speakerRepo.Create(new Speaker { Name = "Sir Other", ProjectId = ProjId }).Result; + var speakersInRepo = _speakerRepo.GetAllSpeakers(ProjId).Result; + + var result = _speakerController.GetProjectSpeakers(ProjId).Result; + Assert.That(result, Is.InstanceOf()); + var value = ((ObjectResult)result).Value; + Assert.That((List)value!, Has.Count.EqualTo(speakersInRepo.Count)); + } + + [Test] + public void TestDeleteProjectSpeakersUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.DeleteProjectSpeakers(ProjId).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestDeleteProjectSpeakers() + { + _ = _speakerRepo.Create(new Speaker { Name = "Sir Other", ProjectId = ProjId }).Result; + Assert.That(_speakerRepo.GetAllSpeakers(ProjId).Result, Is.Not.Empty); + + var result = _speakerController.DeleteProjectSpeakers(ProjId).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(_speakerRepo.GetAllSpeakers(ProjId).Result, Is.Empty); + } + + [Test] + public void TestGetSpeakerUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.GetSpeaker(ProjId, _speaker.Id).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestGetSpeakerNoSpeaker() + { + var result = _speakerController.GetSpeaker(ProjId, "other-id").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestGetSpeakerSpeaker() + { + var result = _speakerController.GetSpeaker(ProjId, _speaker.Id).Result; + Assert.That(result, Is.InstanceOf()); + var value = ((ObjectResult)result).Value; + Assert.That(((Speaker)value!).Name, Is.EqualTo(_speaker.Name)); + } + + [Test] + public void TestCreateSpeakerUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.CreateSpeaker(ProjId, "Miss Novel").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestCreateSpeaker() + { + const string NewName = "Miss Novel"; + var result = _speakerController.CreateSpeaker(ProjId, NewName).Result; + Assert.That(result, Is.InstanceOf()); + var speakerId = ((ObjectResult)result).Value as string; + var speakerInRepo = _speakerRepo.GetSpeaker(ProjId, speakerId!).Result; + Assert.That(speakerInRepo!.Name, Is.EqualTo(NewName)); + } + + [Test] + public void TestDeleteSpeakerUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.DeleteSpeaker(ProjId, _speaker.Id).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestDeleteSpeakerNoSpeaker() + { + var result = _speakerController.DeleteSpeaker(ProjId, "other-id").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestDeleteSpeaker() + { + var result = _speakerController.DeleteSpeaker(ProjId, _speaker.Id).Result; + Assert.That(result, Is.InstanceOf()); + Assert.That(_speakerRepo.GetSpeaker(ProjId, _speaker.Id).Result, Is.Null); + } + + [Test] + public void TestRemoveConsentUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.RemoveConsent(ProjId, _speaker.Id).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestRemoveConsentNoSpeaker() + { + var result = _speakerController.RemoveConsent(ProjId, "other-id").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestRemoveConsent() + { + // Set ConsentType in repo + _speaker.Consent = ConsentType.Audio; + _ = _speakerRepo.Update(_speaker.Id, _speaker); + var consentInRepo = _speakerRepo.GetSpeaker(ProjId, _speaker.Id).Result!.Consent; + Assert.That(consentInRepo, Is.Not.EqualTo(ConsentType.None)); + + // Remove consent + var result = _speakerController.RemoveConsent(ProjId, _speaker.Id).Result; + Assert.That(result, Is.InstanceOf()); + consentInRepo = _speakerRepo.GetSpeaker(ProjId, _speaker.Id).Result!.Consent; + Assert.That(consentInRepo, Is.EqualTo(ConsentType.None)); + + // Try to remove again + result = _speakerController.RemoveConsent(ProjId, _speaker.Id).Result; + Assert.That(((ObjectResult)result).StatusCode, Is.EqualTo(StatusCodes.Status304NotModified)); + } + + [Test] + public void TestUpdateSpeakerNameUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.UpdateSpeakerName(ProjId, _speaker.Id, "Mr. New").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUpdateSpeakerNameNoSpeaker() + { + var result = _speakerController.UpdateSpeakerName(ProjId, "other-id", "Mr. New").Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUpdateSpeakerNameSameName() + { + var result = _speakerController.UpdateSpeakerName(ProjId, _speaker.Id, Name).Result; + Assert.That(((ObjectResult)result).StatusCode, Is.EqualTo(StatusCodes.Status304NotModified)); + } + + [Test] + public void TestUpdateSpeakerNameNewName() + { + const string NewName = "Mr. New"; + var result = _speakerController.UpdateSpeakerName(ProjId, _speaker.Id, NewName).Result; + Assert.That(result, Is.InstanceOf()); + var nameInRepo = _speakerRepo.GetSpeaker(ProjId, _speaker.Id).Result!.Name; + Assert.That(nameInRepo, Is.EqualTo(NewName)); + } + + [Test] + public void TestUploadConsentInvalidArguments() + { + var result = _speakerController.UploadConsent("invalid/projectId", _speaker.Id, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + + result = _speakerController.UploadConsent(ProjId, "invalid/speakerId", _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadConsentUnauthorized() + { + _speakerController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + var result = _speakerController.UploadConsent(ProjId, _speaker.Id, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadConsentNoSpeaker() + { + var result = _speakerController.UploadConsent(ProjId, "other", _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadConsentNullFile() + { + var result = _speakerController.UploadConsent(ProjId, _speaker.Id, new()).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadConsentEmptyFile() + { + // Use 0 for the third argument + _formFile = new FormFile(_stream, 0, 0, "name", FileName) + { + Headers = new HeaderDictionary(), + ContentType = "audio" + }; + _fileUpload = new FileUpload { File = _formFile, Name = FileName }; + var result = _speakerController.UploadConsent(ProjId, _speaker.Id, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadConsentInvalidContentType() + { + _formFile.ContentType = "neither audi0 nor 1mage"; + _fileUpload = new FileUpload { File = _formFile, Name = FileName }; + var result = _speakerController.UploadConsent(ProjId, _speaker.Id, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void TestUploadConsentAddAudioConsent() + { + _formFile.ContentType = "audio/something"; + _fileUpload = new FileUpload { File = _formFile, Name = FileName }; + var result = _speakerController.UploadConsent(ProjId, _speaker.Id, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + var value = (Speaker)((ObjectResult)result).Value!; + Assert.That(value.Consent, Is.EqualTo(ConsentType.Audio)); + var consentInRepo = _speakerRepo.GetSpeaker(ProjId, _speaker.Id).Result!.Consent; + Assert.That(consentInRepo, Is.EqualTo(ConsentType.Audio)); + } + + [Test] + public void TestUploadConsentAddImageConsent() + { + _formFile.ContentType = "image/anything"; + _fileUpload = new FileUpload { File = _formFile, Name = FileName }; + var result = _speakerController.UploadConsent(ProjId, _speaker.Id, _fileUpload).Result; + Assert.That(result, Is.InstanceOf()); + var value = (Speaker)((ObjectResult)result).Value!; + Assert.That(value.Consent, Is.EqualTo(ConsentType.Image)); + var consentInRepo = _speakerRepo.GetSpeaker(ProjId, _speaker.Id).Result!.Consent; + Assert.That(consentInRepo, Is.EqualTo(ConsentType.Image)); + } + + [Test] + public void TestDownloadConsentInvalidArguments() + { + var result = _speakerController.DownloadConsent("invalid/speakerId"); + Assert.That(result, Is.TypeOf()); + } + + [Test] + public void TestDownloadSpeakerFileNoFile() + { + var result = _speakerController.DownloadConsent("speakerId"); + Assert.That(result, Is.TypeOf()); + } + } +} diff --git a/Backend.Tests/Mocks/SpeakerRepositoryMock.cs b/Backend.Tests/Mocks/SpeakerRepositoryMock.cs new file mode 100644 index 0000000000..1ef2aaba85 --- /dev/null +++ b/Backend.Tests/Mocks/SpeakerRepositoryMock.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; + +namespace Backend.Tests.Mocks +{ + public class SpeakerRepositoryMock : ISpeakerRepository + { + private readonly List _speakers; + + public SpeakerRepositoryMock() + { + _speakers = new List(); + } + + public Task> GetAllSpeakers(string projectId) + { + var cloneList = _speakers.Select(speaker => speaker.Clone()).ToList(); + return Task.FromResult(cloneList.Where(speaker => speaker.ProjectId == projectId).ToList()); + } + + public Task GetSpeaker(string projectId, string speakerId) + { + try + { + var foundSpeaker = _speakers.Single(speaker => speaker.Id == speakerId); + return Task.FromResult(foundSpeaker.Clone()); + } + catch (InvalidOperationException) + { + return Task.FromResult(null); + } + } + + public Task Create(Speaker speaker) + { + speaker.Id = Guid.NewGuid().ToString(); + _speakers.Add(speaker.Clone()); + return Task.FromResult(speaker.Clone()); + } + + public Task DeleteAllSpeakers(string projectId) + { + _speakers.Clear(); + return Task.FromResult(true); + } + + public Task Delete(string projectId, string speakerId) + { + var foundSpeaker = _speakers.Single(speaker => speaker.Id == speakerId); + return Task.FromResult(_speakers.Remove(foundSpeaker)); + } + + public Task Update(string speakerId, Speaker speaker) + { + var foundSpeaker = _speakers.Single(ur => ur.Id == speakerId); + if (foundSpeaker is null) + { + return Task.FromResult(ResultOfUpdate.NotFound); + } + + if (foundSpeaker.ContentEquals(speaker)) + { + return Task.FromResult(ResultOfUpdate.NoChange); + } + + var success = _speakers.Remove(foundSpeaker); + if (!success) + { + return Task.FromResult(ResultOfUpdate.NotFound); + } + + _speakers.Add(speaker.Clone()); + return Task.FromResult(ResultOfUpdate.Updated); + } + } +} diff --git a/Backend.Tests/Models/SpeakerTests.cs b/Backend.Tests/Models/SpeakerTests.cs new file mode 100644 index 0000000000..a416103193 --- /dev/null +++ b/Backend.Tests/Models/SpeakerTests.cs @@ -0,0 +1,49 @@ +using BackendFramework.Models; +using NUnit.Framework; + +namespace Backend.Tests.Models +{ + public class SpeakerTests + { + private const string Id = "SpeakerTestsId"; + private const string ProjectId = "SpeakerTestsProjectId"; + private const string Name = "Ms. Given Family"; + private const string FileName = "audio.mp3"; + + [Test] + public void TestClone() + { + var speakerA = new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = ConsentType.Audio }; + Assert.That(speakerA.Equals(speakerA.Clone()), Is.True); + } + + [Test] + public void TestEquals() + { + var speaker = new Speaker { Name = Name, Consent = ConsentType.Audio }; + Assert.That(speaker.Equals(null), Is.False); + Assert.That(new Speaker { Id = "diff-id", ProjectId = ProjectId, Name = Name, Consent = ConsentType.Audio } + .Equals(speaker), Is.False); + Assert.That(new Speaker { Id = Id, ProjectId = "diff-proj-id", Name = Name, Consent = ConsentType.Audio } + .Equals(speaker), Is.False); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = "Mr. Diff", Consent = ConsentType.Audio } + .Equals(speaker), Is.False); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = ConsentType.Image } + .Equals(speaker), Is.False); + } + + [Test] + public void TestHashCode() + { + var code = new Speaker { Name = Name, Consent = ConsentType.Audio }.GetHashCode(); + Assert.That(new Speaker { Id = "diff-id", ProjectId = ProjectId, Name = Name, Consent = ConsentType.Audio } + .GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Speaker { Id = Id, ProjectId = "diff-proj-id", Name = Name, Consent = ConsentType.Audio } + .GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = "Mr. Diff", Consent = ConsentType.Audio } + .GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = ConsentType.Image } + .GetHashCode(), Is.Not.EqualTo(code)); + } + } +} diff --git a/Backend.Tests/Models/WordTests.cs b/Backend.Tests/Models/WordTests.cs index e367b1b86b..d6ec3445bb 100644 --- a/Backend.Tests/Models/WordTests.cs +++ b/Backend.Tests/Models/WordTests.cs @@ -86,7 +86,7 @@ public void TestAppendContainedWordContents() newWord.Flag = newFlag.Clone(); // Add something to newWord in Audio, EditedBy, History. - newWord.Audio.Add(Text); + newWord.Audio.Add(new Pronunciation(Text)); newWord.EditedBy.Add(Text); newWord.History.Add(Text); @@ -100,12 +100,45 @@ public void TestAppendContainedWordContents() Assert.That(updatedDom, Is.Not.Null); Assert.That(oldWord.Flag.Equals(newFlag), Is.True); Assert.That(oldWord.Note.Equals(newNote), Is.True); - Assert.That(oldWord.Audio.Contains(Text), Is.True); + Assert.That(oldWord.Audio.Contains(new Pronunciation(Text)), Is.True); Assert.That(oldWord.EditedBy.Contains(Text), Is.True); Assert.That(oldWord.History.Contains(Text), Is.True); } } + public class PronunciationTest + { + private const string FileName = "file-name.mp3"; + private const string SpeakerId = "1234567890"; + + [Test] + public void TestNotEquals() + { + var pronunciation = new Pronunciation { Protected = false, FileName = FileName, SpeakerId = SpeakerId }; + Assert.That(pronunciation.Equals( + new Pronunciation { Protected = true, FileName = FileName, SpeakerId = SpeakerId }), Is.False); + Assert.That(pronunciation.Equals( + new Pronunciation { Protected = false, FileName = "other-name", SpeakerId = SpeakerId }), Is.False); + Assert.That(pronunciation.Equals( + new Pronunciation { Protected = false, FileName = FileName, SpeakerId = "other-id" }), Is.False); + Assert.That(pronunciation.Equals(null), Is.False); + } + + [Test] + public void TestHashCode() + { + Assert.That( + new Pronunciation { FileName = FileName }.GetHashCode(), + Is.Not.EqualTo(new Pronunciation { FileName = "other-name" }.GetHashCode())); + Assert.That( + new Pronunciation { SpeakerId = SpeakerId }.GetHashCode(), + Is.Not.EqualTo(new Pronunciation { SpeakerId = "other-id" }.GetHashCode())); + Assert.That( + new Pronunciation { Protected = true }.GetHashCode(), + Is.Not.EqualTo(new Pronunciation { Protected = false }.GetHashCode())); + } + } + public class NoteTests { private const string Language = "fr"; diff --git a/Backend.Tests/Services/LiftServiceTests.cs b/Backend.Tests/Services/LiftServiceTests.cs index dc53b992d8..2a82b0922a 100644 --- a/Backend.Tests/Services/LiftServiceTests.cs +++ b/Backend.Tests/Services/LiftServiceTests.cs @@ -11,6 +11,7 @@ namespace Backend.Tests.Services public class LiftServiceTests { private ISemanticDomainRepository _semDomRepo = null!; + private ISpeakerRepository _speakerRepo = null!; private ILiftService _liftService = null!; private const string FileName = "file.lift-ranges"; @@ -21,7 +22,8 @@ public class LiftServiceTests public void Setup() { _semDomRepo = new SemanticDomainRepositoryMock(); - _liftService = new LiftService(_semDomRepo); + _speakerRepo = new SpeakerRepositoryMock(); + _liftService = new LiftService(_semDomRepo, _speakerRepo); } [Test] diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 9d1ae31861..12f768c071 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -48,6 +48,39 @@ public void TestCreateMultipleWords() Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(2)); } + [Test] + public void TestDeleteAudioBadInputNull() + { + var fileName = "audio.mp3"; + var wordInFrontier = _wordRepo.Create( + new Word() { Audio = new() { new() { FileName = fileName } }, ProjectId = ProjId }).Result; + Assert.That(_wordService.Delete("non-project-id", UserId, wordInFrontier.Id, fileName).Result, Is.Null); + Assert.That(_wordService.Delete(ProjId, UserId, "non-word-id", fileName).Result, Is.Null); + Assert.That(_wordService.Delete(ProjId, UserId, wordInFrontier.Id, "non-file-name").Result, Is.Null); + } + + [Test] + public void TestDeleteAudioNotInFrontierNull() + { + var fileName = "audio.mp3"; + var wordNotInFrontier = _wordRepo.Add( + new() { Audio = new() { new() { FileName = fileName } }, ProjectId = ProjId }).Result; + Assert.That(_wordService.Delete(ProjId, UserId, wordNotInFrontier.Id, fileName).Result, Is.Null); + } + + [Test] + public void TestDeleteAudio() + { + var fileName = "audio.mp3"; + var wordInFrontier = _wordRepo.Create( + new Word() { Audio = new() { new() { FileName = fileName } }, ProjectId = ProjId }).Result; + var result = _wordService.Delete(ProjId, UserId, wordInFrontier.Id, fileName).Result; + Assert.That(result!.EditedBy.Last(), Is.EqualTo(UserId)); + Assert.That(result!.History.Last(), Is.EqualTo(wordInFrontier.Id)); + Assert.That(_wordRepo.IsInFrontier(ProjId, result.Id).Result, Is.True); + Assert.That(_wordRepo.IsInFrontier(ProjId, wordInFrontier.Id).Result, Is.False); + } + [Test] public void TestUpdateNotInFrontierFalse() { diff --git a/Backend.Tests/Util.cs b/Backend.Tests/Util.cs index a92e72cb34..b5fe213319 100644 --- a/Backend.Tests/Util.cs +++ b/Backend.Tests/Util.cs @@ -53,7 +53,7 @@ public static Word RandomWord(string? projId = null) Modified = RandString(), Plural = RandString(), History = new List(), - Audio = new List(), + Audio = new List(), EditedBy = new List { RandString(), RandString() }, ProjectId = projId ?? RandString(), Senses = new List { RandomSense(), RandomSense(), RandomSense() }, diff --git a/Backend/Contexts/SpeakerContext.cs b/Backend/Contexts/SpeakerContext.cs new file mode 100644 index 0000000000..f65c3e08c0 --- /dev/null +++ b/Backend/Contexts/SpeakerContext.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace BackendFramework.Contexts +{ + [ExcludeFromCodeCoverage] + public class SpeakerContext : ISpeakerContext + { + private readonly IMongoDatabase _db; + + public SpeakerContext(IOptions options) + { + var client = new MongoClient(options.Value.ConnectionString); + _db = client.GetDatabase(options.Value.CombineDatabase); + } + + public IMongoCollection Speakers => _db.GetCollection("SpeakersCollection"); + } +} diff --git a/Backend/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index 4ccd93df5e..da8efecf33 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -61,14 +61,28 @@ public IActionResult DownloadAudioFile(string projectId, string wordId, string f } /// - /// Adds a pronunciation to a and saves - /// locally to ~/.CombineFiles/{ProjectId}/Import/ExtractedLocation/Lift/audio + /// Adds a pronunciation to a specified project word + /// and saves locally to ~/.CombineFiles/{ProjectId}/Import/ExtractedLocation/Lift/audio /// /// Id of updated word [HttpPost("upload", Name = "UploadAudioFile")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task UploadAudioFile(string projectId, string wordId, [FromForm] FileUpload fileUpload) + { + return await UploadAudioFile(projectId, wordId, "", fileUpload); + } + + + /// + /// Adds a pronunciation with a specified speaker to a project word + /// and saves locally to ~/.CombineFiles/{ProjectId}/Import/ExtractedLocation/Lift/audio + /// + /// Id of updated word + [HttpPost("upload/{speakerId}", Name = "UploadAudioFileWithSpeaker")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] + public async Task UploadAudioFile(string projectId, string wordId, string speakerId, + [FromForm] FileUpload fileUpload) { if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) { @@ -115,7 +129,8 @@ public async Task UploadAudioFile(string projectId, string wordId { return NotFound(wordId); } - word.Audio.Add(Path.GetFileName(fileUpload.FilePath)); + var audio = new Pronunciation(Path.GetFileName(fileUpload.FilePath), speakerId); + word.Audio.Add(audio); // Update the word with new audio file await _wordService.Update(projectId, userId, wordId, word); diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs new file mode 100644 index 0000000000..4e83d3a0d2 --- /dev/null +++ b/Backend/Controllers/SpeakerController.cs @@ -0,0 +1,297 @@ +using System.Collections.Generic; +using IO = System.IO; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace BackendFramework.Controllers +{ + [Authorize] + [Produces("application/json")] + [Route("v1/projects/{projectId}/speakers")] + public class SpeakerController : Controller + { + private readonly ISpeakerRepository _speakerRepo; + private readonly IPermissionService _permissionService; + + public SpeakerController(ISpeakerRepository speakerRepo, IPermissionService permissionService) + { + _speakerRepo = speakerRepo; + _permissionService = permissionService; + } + + /// Gets all s for specified projectId + /// List of Speakers + [HttpGet(Name = "GetProjectSpeakers")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task GetProjectSpeakers(string projectId) + { + // Check permissions + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) + { + return Forbid(); + } + + // Return speakers + return Ok(await _speakerRepo.GetAllSpeakers(projectId)); + } + + /// Deletes all s for specified projectId + /// bool: true if success; false if no speakers in project + [HttpDelete(Name = "DeleteProjectSpeakers")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] + public async Task DeleteProjectSpeakers(string projectId) + { + // Check permissions + if (!await _permissionService.IsSiteAdmin(HttpContext)) + { + return Forbid(); + } + + // Delete speakers and return success + return Ok(await _speakerRepo.DeleteAllSpeakers(projectId)); + } + + /// Gets the for the specified projectId and speakerId + /// Speaker + [HttpGet("{speakerId}", Name = "GetSpeaker")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] + public async Task GetSpeaker(string projectId, string speakerId) + { + // Check permissions + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) + { + return Forbid(); + } + + // Ensure the speaker exists + var speaker = await _speakerRepo.GetSpeaker(projectId, speakerId); + if (speaker is null) + { + return NotFound(speakerId); + } + + // Return speaker + return Ok(speaker); + } + + /// Creates a for the specified projectId + /// Id of created Speaker + [HttpGet("/create/{name}", Name = "CreateSpeaker")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] + public async Task CreateSpeaker(string projectId, string name) + { + // Check permissions + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.DeleteEditSettingsAndUsers, projectId)) + { + return Forbid(); + } + + // Create speaker and return id + var speaker = new Speaker { Name = name, ProjectId = projectId }; + return Ok((await _speakerRepo.Create(speaker)).Id); + } + + /// Deletes the for the specified projectId and speakerId + /// bool: true if success; false if failure + [HttpDelete("{speakerId}", Name = "DeleteSpeaker")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] + public async Task DeleteSpeaker(string projectId, string speakerId) + { + // Check permissions + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.DeleteEditSettingsAndUsers, projectId)) + { + return Forbid(); + } + + // Ensure the speaker exists + if (await _speakerRepo.GetSpeaker(projectId, speakerId) is null) + { + return NotFound(speakerId); + } + + // Delete speaker and return success + return Ok(await _speakerRepo.Delete(projectId, speakerId)); + } + + /// Removes consent of the for specified projectId and speakerId + /// Id of updated Speaker + [HttpDelete("consent/{speakerId}", Name = "RemoveConsent")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] + public async Task RemoveConsent(string projectId, string speakerId) + { + // Check permissions + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.DeleteEditSettingsAndUsers, projectId)) + { + return Forbid(); + } + + // Ensure the speaker exists + var speaker = await _speakerRepo.GetSpeaker(projectId, speakerId); + if (speaker is null) + { + return NotFound(speakerId); + } + + // Delete consent file + if (speaker.Consent is ConsentType.None) + { + return StatusCode(StatusCodes.Status304NotModified, speakerId); + } + var path = FileStorage.GenerateConsentFilePath(speaker.Id); + if (IO.File.Exists(path)) + { + IO.File.Delete(path); + } + + // Update speaker and return result with id + speaker.Consent = ConsentType.None; + return await _speakerRepo.Update(speakerId, speaker) switch + { + ResultOfUpdate.NotFound => NotFound(speakerId), + ResultOfUpdate.Updated => Ok(speakerId), + _ => StatusCode(StatusCodes.Status304NotModified, speakerId) + }; + } + + /// Updates the 's name for the specified projectId and speakerId + /// Id of updated Speaker + [HttpGet("update/{speakerId}/{name}", Name = "UpdateSpeakerName")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] + public async Task UpdateSpeakerName(string projectId, string speakerId, string name) + { + // Check permissions + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.DeleteEditSettingsAndUsers, projectId)) + { + return Forbid(); + } + + // Ensure the speaker exists + var speaker = await _speakerRepo.GetSpeaker(projectId, speakerId); + if (speaker is null) + { + return NotFound(speakerId); + } + + // Update name and return result with id + speaker.Name = name; + return await _speakerRepo.Update(speakerId, speaker) switch + { + ResultOfUpdate.NotFound => NotFound(speakerId), + ResultOfUpdate.Updated => Ok(speakerId), + _ => StatusCode(StatusCodes.Status304NotModified, speakerId) + }; + } + + /// Saves a consent file locally and updates the specified + /// Updated speaker + [HttpPost("consent/{speakerId}", Name = "UploadConsent")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] + public async Task UploadConsent( + string projectId, string speakerId, [FromForm] FileUpload upload) + { + // Sanitize user input + try + { + projectId = Sanitization.SanitizeId(projectId); + speakerId = Sanitization.SanitizeId(speakerId); + } + catch + { + return new UnsupportedMediaTypeResult(); + } + + // Check permissions + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) + { + return Forbid(); + } + + // Ensure the speaker exists + var speaker = await _speakerRepo.GetSpeaker(projectId, speakerId); + if (speaker is null) + { + return NotFound(speakerId); + } + + // Ensure file is valid + var file = upload.File; + if (file is null) + { + return BadRequest("Null File"); + } + if (file.Length == 0) + { + return BadRequest("Empty File"); + } + if (file.ContentType.Contains("audio")) + { + speaker.Consent = ConsentType.Audio; + } + else if (file.ContentType.Contains("image")) + { + speaker.Consent = ConsentType.Image; + } + else + { + return BadRequest("File should be audio or image"); + } + + // Copy file data to a new local file + var path = FileStorage.GenerateConsentFilePath(speakerId); + await using (var fs = new IO.FileStream(path, IO.FileMode.OpenOrCreate)) + { + await file.CopyToAsync(fs); + } + + // Update and return speaker + return await _speakerRepo.Update(speakerId, speaker) switch + { + ResultOfUpdate.NotFound => NotFound(speaker), + _ => Ok(speaker), + }; + } + + /// Get speaker's consent + /// Stream of local audio/image file + [AllowAnonymous] + [HttpGet("consent/{speakerId}", Name = "DownloadConsent")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileContentResult))] + public IActionResult DownloadConsent(string speakerId) + { + // SECURITY: Omitting authentication so the frontend can use the API endpoint directly as a URL. + // if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry)) + // { + // return Forbid(); + // } + + // Sanitize user input + try + { + speakerId = Sanitization.SanitizeId(speakerId); + } + catch + { + return new UnsupportedMediaTypeResult(); + } + + // Ensure file exists + var path = FileStorage.GenerateConsentFilePath(speakerId); + if (!IO.File.Exists(path)) + { + return NotFound(speakerId); + } + + // Return file as stream + return File(IO.File.OpenRead(path), "application/octet-stream"); + } + } +} diff --git a/Backend/Helper/FileStorage.cs b/Backend/Helper/FileStorage.cs index 9c4de12255..60b02ffa4b 100644 --- a/Backend/Helper/FileStorage.cs +++ b/Backend/Helper/FileStorage.cs @@ -13,6 +13,7 @@ public static class FileStorage { private const string CombineFilesDir = ".CombineFiles"; private const string AvatarsDir = "Avatars"; + private const string ConsentDir = "Consent"; private static readonly string ImportExtractedLocation = Path.Combine("Import", "ExtractedLocation"); private static readonly string LiftImportSuffix = Path.Combine(ImportExtractedLocation, "Lift"); private static readonly string AudioPathSuffix = Path.Combine(LiftImportSuffix, "audio"); @@ -20,7 +21,7 @@ public static class FileStorage public enum FileType { Audio, - Avatar + Avatar, } /// Indicates that an error occurred locating the current user's home directory. @@ -51,9 +52,7 @@ public static string GenerateAudioFilePathForWord(string projectId, string wordI /// Throws when id invalid. public static string GenerateAudioFilePath(string projectId, string fileName) { - projectId = Sanitization.SanitizeId(projectId); - - return GenerateProjectFilePath(projectId, AudioPathSuffix, fileName); + return GenerateProjectFilePath(Sanitization.SanitizeId(projectId), AudioPathSuffix, fileName); } /// @@ -62,9 +61,7 @@ public static string GenerateAudioFilePath(string projectId, string fileName) /// Throws when id invalid. public static string GenerateAudioFileDirPath(string projectId, bool createDir = true) { - projectId = Sanitization.SanitizeId(projectId); - - return GenerateProjectDirPath(projectId, AudioPathSuffix, createDir); + return GenerateProjectDirPath(Sanitization.SanitizeId(projectId), AudioPathSuffix, createDir); } /// @@ -74,9 +71,7 @@ public static string GenerateAudioFileDirPath(string projectId, bool createDir = /// This function is not expected to be used often. public static string GenerateImportExtractedLocationDirPath(string projectId, bool createDir = true) { - projectId = Sanitization.SanitizeId(projectId); - - return GenerateProjectDirPath(projectId, ImportExtractedLocation, createDir); + return GenerateProjectDirPath(Sanitization.SanitizeId(projectId), ImportExtractedLocation, createDir); } /// @@ -85,9 +80,7 @@ public static string GenerateImportExtractedLocationDirPath(string projectId, bo /// Throws when id invalid. public static string GenerateLiftImportDirPath(string projectId, bool createDir = true) { - projectId = Sanitization.SanitizeId(projectId); - - return GenerateProjectDirPath(projectId, LiftImportSuffix, createDir); + return GenerateProjectDirPath(Sanitization.SanitizeId(projectId), LiftImportSuffix, createDir); } /// @@ -96,9 +89,16 @@ public static string GenerateLiftImportDirPath(string projectId, bool createDir /// Throws when id invalid. public static string GenerateAvatarFilePath(string userId) { - userId = Sanitization.SanitizeId(userId); + return GenerateFilePath(AvatarsDir, Sanitization.SanitizeId(userId), FileType.Avatar); + } - return GenerateFilePath(AvatarsDir, userId, FileType.Avatar); + /// + /// Generate the path to where Consent audio/images are stored. + /// + /// Throws when id invalid. + public static string GenerateConsentFilePath(string speakerId) + { + return GenerateFilePath(ConsentDir, Sanitization.SanitizeId(speakerId)); } /// @@ -107,9 +107,7 @@ public static string GenerateAvatarFilePath(string userId) /// Throws when id invalid. public static string GetProjectDir(string projectId) { - projectId = Sanitization.SanitizeId(projectId); - - return GenerateProjectDirPath(projectId, "", false); + return GenerateProjectDirPath(Sanitization.SanitizeId(projectId), "", false); } /// Get the path to the home directory of the current user. @@ -172,8 +170,7 @@ private static string GenerateProjectFilePath( private static string GenerateFilePath(string suffixPath, string fileName) { - var dirPath = GenerateDirPath(suffixPath, true); - return Path.Combine(dirPath, fileName); + return Path.Combine(GenerateDirPath(suffixPath, true), fileName); } private static string GenerateFilePath(string suffixPath, string fileNameSuffix, FileType type) diff --git a/Backend/Interfaces/ISpeakerContext.cs b/Backend/Interfaces/ISpeakerContext.cs new file mode 100644 index 0000000000..6d6707d125 --- /dev/null +++ b/Backend/Interfaces/ISpeakerContext.cs @@ -0,0 +1,10 @@ +using BackendFramework.Models; +using MongoDB.Driver; + +namespace BackendFramework.Interfaces +{ + public interface ISpeakerContext + { + IMongoCollection Speakers { get; } + } +} diff --git a/Backend/Interfaces/ISpeakerRepository.cs b/Backend/Interfaces/ISpeakerRepository.cs new file mode 100644 index 0000000000..f0ccf1bcff --- /dev/null +++ b/Backend/Interfaces/ISpeakerRepository.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Models; + +namespace BackendFramework.Interfaces +{ + public interface ISpeakerRepository + { + Task> GetAllSpeakers(string projectId); + Task GetSpeaker(string projectId, string speakerId); + Task Create(Speaker speaker); + Task Delete(string projectId, string speakerId); + Task DeleteAllSpeakers(string projectId); + Task Update(string speakerId, Speaker speaker); + } +} diff --git a/Backend/Models/Speaker.cs b/Backend/Models/Speaker.cs new file mode 100644 index 0000000000..e1333f0b07 --- /dev/null +++ b/Backend/Models/Speaker.cs @@ -0,0 +1,74 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BackendFramework.Models +{ + /// + /// Project Speaker that can have a consent form and can be associated with Pronunciations. + /// The speaker's consent for will have file name equal to .Id of the Speaker. + /// + public class Speaker + { + [Required] + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + + [Required] + [BsonElement("projectId")] + public string ProjectId { get; set; } + + [Required] + [BsonElement("name")] + public string Name { get; set; } + + [Required] + [BsonElement("consent")] + public ConsentType Consent { get; set; } + + public Speaker() + { + Id = ""; + ProjectId = ""; + Name = ""; + } + + public Speaker Clone() + { + return new Speaker + { + Id = Id, + ProjectId = ProjectId, + Name = Name, + Consent = Consent + }; + } + + public bool ContentEquals(Speaker other) + { + return ProjectId.Equals(other.ProjectId, StringComparison.Ordinal) && + Name.Equals(other.Name, StringComparison.Ordinal) && + Consent == other.Consent; + } + + public override bool Equals(object? obj) + { + return obj is Speaker other && GetType() == obj.GetType() && + Id.Equals(other.Id, StringComparison.Ordinal) && ContentEquals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Id, ProjectId, Name, Consent); + } + } + + public enum ConsentType + { + None = 0, + Audio, + Image + } +} diff --git a/Backend/Models/Word.cs b/Backend/Models/Word.cs index 9d36cc2b7b..97cd1838ab 100644 --- a/Backend/Models/Word.cs +++ b/Backend/Models/Word.cs @@ -39,7 +39,7 @@ public class Word [Required] [BsonElement("audio")] - public List Audio { get; set; } + public List Audio { get; set; } [Required] [BsonElement("created")] @@ -91,7 +91,7 @@ public Word() OtherField = ""; ProjectId = ""; Accessibility = Status.Active; - Audio = new List(); + Audio = new List(); EditedBy = new List(); History = new List(); Senses = new List(); @@ -112,7 +112,7 @@ public Word Clone() OtherField = OtherField, ProjectId = ProjectId, Accessibility = Accessibility, - Audio = new List(), + Audio = new List(), EditedBy = new List(), History = new List(), Senses = new List(), @@ -120,9 +120,9 @@ public Word Clone() Flag = Flag.Clone(), }; - foreach (var file in Audio) + foreach (var audio in Audio) { - clone.Audio.Add(file); + clone.Audio.Add(audio.Clone()); } foreach (var id in EditedBy) { @@ -243,6 +243,69 @@ public bool AppendContainedWordContents(Word other, string userId) } } + /// A pronunciation associated with a Word. + public class Pronunciation + { + /// The audio file name. + [Required] + [BsonElement("fileName")] + public string FileName { get; set; } + + /// The speaker id. + [Required] + [BsonElement("speakerId")] + public string SpeakerId { get; set; } + + /// For imported audio, to prevent modification or deletion (unless the word is deleted). + [Required] + [BsonElement("protected")] + public bool Protected { get; set; } + + public Pronunciation() + { + FileName = ""; + SpeakerId = ""; + Protected = false; + } + + public Pronunciation(string fileName) : this() + { + FileName = fileName; + } + + public Pronunciation(string fileName, string speakerId) : this(fileName) + { + SpeakerId = speakerId; + } + + public Pronunciation Clone() + { + return new Pronunciation + { + FileName = FileName, + SpeakerId = SpeakerId, + Protected = Protected + }; + } + + public override bool Equals(object? obj) + { + if (obj is not Pronunciation other || GetType() != obj.GetType()) + { + return false; + } + + return FileName.Equals(other.FileName, StringComparison.Ordinal) && + SpeakerId.Equals(other.SpeakerId, StringComparison.Ordinal) && + Protected == other.Protected; + } + + public override int GetHashCode() + { + return HashCode.Combine(FileName, SpeakerId, Protected); + } + } + /// A note associated with a Word, compatible with FieldWorks. public class Note { diff --git a/Backend/Repositories/SpeakerRepository.cs b/Backend/Repositories/SpeakerRepository.cs new file mode 100644 index 0000000000..c06dfca5d4 --- /dev/null +++ b/Backend/Repositories/SpeakerRepository.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using MongoDB.Driver; + +namespace BackendFramework.Repositories +{ + /// Atomic database functions for s. + [ExcludeFromCodeCoverage] + public class SpeakerRepository : ISpeakerRepository + { + private readonly ISpeakerContext _speakerDatabase; + + public SpeakerRepository(ISpeakerContext collectionSettings) + { + _speakerDatabase = collectionSettings; + } + + /// Finds all s in specified + public async Task> GetAllSpeakers(string projectId) + { + return await _speakerDatabase.Speakers.Find(u => u.ProjectId == projectId).ToListAsync(); + } + + /// Removes all s for specified + /// A bool: success of operation + public async Task DeleteAllSpeakers(string projectId) + { + return (await _speakerDatabase.Speakers.DeleteManyAsync(u => u.ProjectId == projectId)).DeletedCount > 0; + } + + /// Finds with specified projectId and speakerId + public async Task GetSpeaker(string projectId, string speakerId) + { + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.And(filterDef.Eq( + x => x.ProjectId, projectId), filterDef.Eq(x => x.Id, speakerId)); + + var speakerList = await _speakerDatabase.Speakers.FindAsync(filter); + try + { + return await speakerList.FirstAsync(); + } + catch (InvalidOperationException) + { + return null; + } + } + + /// Adds a + /// The Speaker created + public async Task Create(Speaker speaker) + { + await _speakerDatabase.Speakers.InsertOneAsync(speaker); + return speaker; + } + + /// Removes with specified projectId and speakerId + /// A bool: success of operation + public async Task Delete(string projectId, string speakerId) + { + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.And( + filterDef.Eq(x => x.ProjectId, projectId), + filterDef.Eq(x => x.Id, speakerId)); + + return (await _speakerDatabase.Speakers.DeleteOneAsync(filter)).DeletedCount > 0; + } + + /// Updates with specified speakerId + /// A enum: success of operation + public async Task Update(string speakerId, Speaker speaker) + { + var filter = Builders.Filter.Eq(x => x.Id, speakerId); + var updateDef = Builders.Update + .Set(x => x.ProjectId, speaker.ProjectId) + .Set(x => x.Name, speaker.Name) + .Set(x => x.Consent, speaker.Consent); + var updateResult = await _speakerDatabase.Speakers.UpdateOneAsync(filter, updateDef); + + return !updateResult.IsAcknowledged + ? ResultOfUpdate.NotFound + : updateResult.ModifiedCount > 0 + ? ResultOfUpdate.Updated + : ResultOfUpdate.NoChange; + } + } +} diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index b9243c3f52..03c6fdc103 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -39,7 +39,25 @@ protected override void InsertPronunciationIfNeeded( { Writer.WriteStartElement("pronunciation"); Writer.WriteStartElement("media"); - Writer.WriteAttributeString("href", Path.GetFileName(phonetic.Forms.First().Form)); + var forms = new List(phonetic.Forms); + var href = forms.Find(f => f.WritingSystemId == "href"); + if (href is null) + { + continue; + } + Writer.WriteAttributeString("href", Path.GetFileName(href.Form)); + // If there is speaker info, it was added as an "en" MultiText + var label = forms.Find(f => f.WritingSystemId == "en"); + if (label is not null) + { + Writer.WriteStartElement("label"); + Writer.WriteStartElement("form"); + Writer.WriteAttributeString("lang", label.WritingSystemId); + Writer.WriteElementString("text", label.Form); + Writer.WriteEndElement(); + Writer.WriteEndElement(); + } + Writer.WriteEndElement(); Writer.WriteEndElement(); } @@ -92,6 +110,7 @@ protected MissingProjectException(SerializationInfo info, StreamingContext conte public class LiftService : ILiftService { private readonly ISemanticDomainRepository _semDomRepo; + private readonly ISpeakerRepository _speakerRepo; /// A dictionary shared by all Projects for storing and retrieving paths to exported projects. private readonly Dictionary _liftExports; @@ -99,9 +118,10 @@ public class LiftService : ILiftService private readonly Dictionary _liftImports; private const string InProgress = "IN_PROGRESS"; - public LiftService(ISemanticDomainRepository semDomRepo) + public LiftService(ISemanticDomainRepository semDomRepo, ISpeakerRepository speakerRepo) { _semDomRepo = semDomRepo; + _speakerRepo = speakerRepo; if (!Sldr.IsInitialized) { @@ -240,9 +260,11 @@ public async Task LiftExport( var zipDir = Path.Combine(tempExportDir, projNameAsPath); Directory.CreateDirectory(zipDir); - // Add audio dir inside zip dir. + // Add audio dir, consent dir inside zip dir. var audioDir = Path.Combine(zipDir, "audio"); Directory.CreateDirectory(audioDir); + var consentDir = Path.Combine(zipDir, "consent"); + Directory.CreateDirectory(consentDir); var liftPath = Path.Combine(zipDir, projNameAsPath + ".lift"); // noBOM will work with PrinceXML @@ -270,6 +292,9 @@ public async Task LiftExport( var activeWords = frontier.Where( x => x.Senses.Any(s => s.Accessibility == Status.Active || s.Accessibility == Status.Protected)).ToList(); + // Get all project speakers for exporting audio and consents. + var projSpeakers = await _speakerRepo.GetAllSpeakers(projectId); + // All words in the frontier with any senses are considered current. // The Combine does not import senseless entries and the interface is supposed to prevent creating them. // So the words found in allWords with no matching guid in activeWords are exported as 'deleted'. @@ -297,7 +322,7 @@ public async Task LiftExport( AddNote(entry, wordEntry); AddVern(entry, wordEntry, proj.VernacularWritingSystem.Bcp47); AddSenses(entry, wordEntry); - await AddAudio(entry, wordEntry, audioDir, projectId); + await AddAudio(entry, wordEntry.Audio, audioDir, projectId, projSpeakers); liftWriter.Add(entry); } @@ -310,13 +335,28 @@ public async Task LiftExport( AddNote(entry, wordEntry); AddVern(entry, wordEntry, proj.VernacularWritingSystem.Bcp47); AddSenses(entry, wordEntry); - await AddAudio(entry, wordEntry, audioDir, projectId); + await AddAudio(entry, wordEntry.Audio, audioDir, projectId, projSpeakers); liftWriter.AddDeletedEntry(entry); } liftWriter.End(); + // Add consent files to export directory + foreach (var speaker in projSpeakers) + { + if (speaker.Consent != ConsentType.None) + { + var src = FileStorage.GenerateConsentFilePath(speaker.Id); + if (File.Exists(src)) + { + var dest = Path.Combine(consentDir, speaker.Id); + File.Copy(src, dest, true); + + } + } + } + // Export semantic domains to lift-ranges if (proj.SemanticDomains.Count != 0 || CopyLiftRanges(proj.Id, rangesDest) is null) { @@ -503,13 +543,14 @@ private static void AddSenses(LexEntry entry, Word wordEntry) } /// Adds pronunciation audio of a word to be written out to lift - private static async Task AddAudio(LexEntry entry, Word wordEntry, string path, string projectId) + private static async Task AddAudio(LexEntry entry, List pronunciations, string path, + string projectId, List projectSpeakers) { - foreach (var audioFile in wordEntry.Audio) + foreach (var audio in pronunciations) { var lexPhonetic = new LexPhonetic(); - var src = FileStorage.GenerateAudioFilePath(projectId, audioFile); - var dest = Path.Combine(path, audioFile); + var src = FileStorage.GenerateAudioFilePath(projectId, audio.FileName); + var dest = Path.Combine(path, audio.FileName); if (!File.Exists(src)) continue; if (Path.GetExtension(dest).Equals(".webm", StringComparison.OrdinalIgnoreCase)) @@ -522,8 +563,17 @@ private static async Task AddAudio(LexEntry entry, Word wordEntry, string path, File.Copy(src, dest, true); } - var proMultiText = new LiftMultiText { { "href", dest } }; - lexPhonetic.MergeIn(MultiText.Create(proMultiText)); + lexPhonetic.MergeIn(MultiText.Create(new LiftMultiText { { "href", dest } })); + // If audio has speaker, include speaker info as a pronunciation label + if (!audio.Protected && !string.IsNullOrEmpty(audio.SpeakerId)) + { + var speaker = projectSpeakers.Find(s => s.Id == audio.SpeakerId); + if (speaker is not null) + { + var text = new LiftMultiText { { "en", $"Speaker #{speaker.Id}: {speaker.Name}" } }; + lexPhonetic.MergeIn(MultiText.Create(text)); + } + } entry.Pronunciations.Add(lexPhonetic); } } @@ -803,13 +853,10 @@ public void FinishEntry(LiftEntry entry) // Only add audio if the files exist if (Directory.Exists(extractedAudioDir)) { - // Add audio foreach (var pro in entry.Pronunciations) { - // get path to audio file in lift package at - // ~/{projectId}/Import/ExtractedLocation/Lift/audio/{audioFile}.mp3 - var audioFile = pro.Media.First().Url; - newWord.Audio.Add(audioFile); + // Add audio with Protected = true to prevent modifying or deleting imported audio + newWord.Audio.Add(new Pronunciation(pro.Media.First().Url) { Protected = true }); } } @@ -902,7 +949,7 @@ public void MergeInMedia(LiftObject pronunciation, string href, LiftMultiText ca { var entry = (LiftEntry)pronunciation; var phonetic = new LiftPhonetic(); - var url = new LiftUrlRef { Url = href }; + var url = new LiftUrlRef { Url = href, Label = caption }; phonetic.Media.Add(url); entry.Pronunciations.Add(phonetic); } diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index c045745a71..9f40ed2621 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -50,7 +50,6 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords } // Remove duplicates. - parent.Audio = parent.Audio.Distinct().ToList(); parent.History = parent.History.Distinct().ToList(); return parent; } diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 3072a12cce..60b732dc08 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -94,15 +94,19 @@ public async Task Delete(string projectId, string userId, string wordId) return null; } - var wordIsInFrontier = await _wordRepo.DeleteFrontier(projectId, wordId); + var audioToRemove = wordWithAudioToDelete.Audio.Find(a => a.FileName == fileName); + if (audioToRemove is null) + { + return null; + } // We only want to update words that are in the frontier - if (!wordIsInFrontier) + if (!await _wordRepo.DeleteFrontier(projectId, wordId)) { - return wordWithAudioToDelete; + return null; } - wordWithAudioToDelete.Audio.Remove(fileName); + wordWithAudioToDelete.Audio.Remove(audioToRemove); wordWithAudioToDelete.History.Add(wordId); return await Create(userId, wordWithAudioToDelete); diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 50f3a9c73e..aa4657437a 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -186,6 +186,10 @@ public void ConfigureServices(IServiceCollection services) // Register concrete types for dependency injection + // Banner types + services.AddTransient(); + services.AddTransient(); + // Email types services.AddTransient(); services.AddTransient(); @@ -213,6 +217,17 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + // Semantic Domain types + services.AddSingleton(); + services.AddSingleton(); + + // Speaker types + services.AddTransient(); + services.AddTransient(); + + // Statistics types + services.AddSingleton(); + // User types services.AddTransient(); services.AddTransient(); @@ -230,17 +245,6 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - - // Banner types - services.AddTransient(); - services.AddTransient(); - - // Semantic Domain types - services.AddSingleton(); - services.AddSingleton(); - - // Statistics types - services.AddSingleton(); } /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/docs/user_guide/docs/dataEntry.md b/docs/user_guide/docs/dataEntry.md index 78744dbd08..2445959e02 100644 --- a/docs/user_guide/docs/dataEntry.md +++ b/docs/user_guide/docs/dataEntry.md @@ -32,11 +32,23 @@ are associated with the entry and not individual senses. To record audio, there is a red circle button. For each recorded audio, there is a green triangle button. -**With a mouse:** Click-and-hold the red circle to record. Click a green triangle to play its audio, or shift-click to +**With a mouse:** Click-and-hold the red circle to record. Click a green triangle to play its audio, or shift click to delete its recording. **On a touch screen:** Press-and-hold the red circle to record. Tap a green triangle to play its audio, or -press-and-hold to bring up a menu (with options to play or delete). +press-and-hold to bring up a menu with options. + +#### Add a speaker to audio recordings + +Click the speaker icon in the top bar to see a list of all available speakers and select the current speaker. This +speaker will be automatically associated with every audio recording until you log out or select a different speaker. + +The speaker associated with a recording can be seen by hovering over its play icon, the green triangle. To change a +recording's speaker, right click the green triangle (or press-and-hold on a touch screen). + +!!! note "Note" + + Imported audio cannot be deleted or have a speaker added. ## New Entry with Duplicate Vernacular Form {#new-entry-with-duplicate-vernacular-form} diff --git a/docs/user_guide/docs/project.md b/docs/user_guide/docs/project.md index 03ad7909be..49b808796f 100644 --- a/docs/user_guide/docs/project.md +++ b/docs/user_guide/docs/project.md @@ -124,6 +124,24 @@ Either search existing users (shows all users with the search term in their name new users by email address (they will be automatically added to the project when they make an account via the invitation). +#### Manage Speakers + +Speakers are distinct from users. A speaker can be associate with audio recording of words. Use the + icon at the bottom +of this section to add a speaker. Beside each added speaker are buttons to delete them, edit their name, and add a +consent for use of their recorded voice. The supported methods for adding consent are to (1) record an audio file or (2) +upload an image file. + +When project users are in Data Entry or Review Entries, a speaker icon will be available in the top bar. Users can click +that button to see a list of all available speakers and select the current speaker, this speaker will be automatically +associated with every audio recording made by the user until they log out or select a different speaker. + +The speaker associated with a recording can be seen by hovering over its play icon. To change a recording's speaker, +right click the play icon (or press and hold on a touch screen to bring up a menu). + +When the project is exported from The Combine, speaker names (and ids) will be added as a pronunciation labels in the +LIFT file. All consent files for project speakers will be added to a "consent" subfolder of the export (with speaker ids +used for the file names). + ### Import/Export ![Import/Export](images/projectSettings4Port.png){width=750 .center} @@ -148,6 +166,16 @@ project name with a timestamp affixed. A project that has reached hundreds of MB in size may take multiple minutes to export. +!!! note "Note" + + Project settings, project users, and word flags are not exported. + +#### Export pronunciation speakers + +When a project is exported from TheCombine and imported into FieldWorks, if a pronunciation has an associated speaker, +the speaker name and id will be added as a pronunciation label. Consent files will be exported with speaker id used for +the file name. The consent files can be found in the zipped export, but will not be imported into FieldWorks. + ### Workshop Schedule {#workshop-schedule} ![Workshop Schedule](images/projectSettings5Sched.png){width=750 .center} diff --git a/maintenance/scripts/db_update_audio_type.py b/maintenance/scripts/db_update_audio_type.py new file mode 100755 index 0000000000..5b2ea027e9 --- /dev/null +++ b/maintenance/scripts/db_update_audio_type.py @@ -0,0 +1,59 @@ +#! /usr/bin/env python3 + +import argparse +import logging +from typing import Any, Dict + +from bson.binary import UuidRepresentation +from bson.codec_options import CodecOptions +from bson.objectid import ObjectId +from pymongo import MongoClient + + +def parse_args() -> argparse.Namespace: + """Define command line arguments for parser.""" + parser = argparse.ArgumentParser( + description="Convert all audio entries from string to a pronunciation object", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("--host", default="localhost", help="Database hostname") + parser.add_argument("--port", "-p", default=27017, help="Database connection port") + parser.add_argument( + "--verbose", "-v", action="store_true", help="Print info while running script." + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + logging_level = logging.INFO if args.verbose else logging.WARNING + logging.basicConfig(format="%(levelname)s:%(message)s", level=logging_level) + + client: MongoClient[Dict[str, Any]] = MongoClient(args.host, args.port) + db = client.CombineDatabase + codec_opts: CodecOptions[Dict[str, Any]] = CodecOptions( + uuid_representation=UuidRepresentation.PYTHON_LEGACY + ) + + query: Dict[str, Any] = {"audio": {"$ne": []}} + for collection_name in ["FrontierCollection", "WordsCollection"]: + updates: Dict[ObjectId, Any] = {} + curr_collection = db.get_collection(collection_name, codec_options=codec_opts) + total_docs = curr_collection.count_documents({}) + for word in curr_collection.find(query): + new_audio = [] + for aud in word["audio"]: + if isinstance(aud, str): + new_audio.append({"speakerId": "", "protected": True, "fileName": aud}) + else: + new_audio.append(aud) + word["audio"] = new_audio + updates[ObjectId(word["_id"])] = word + logging.info(f"Updating {len(updates)}/{total_docs} documents in {collection_name}.") + for obj_id, update in updates.items(): + curr_collection.update_one({"_id": obj_id}, {"$set": update}) + + +if __name__ == "__main__": + main() diff --git a/maintenance/scripts/db_update_sem_dom_in_senses.py b/maintenance/scripts/db_update_sem_dom_in_senses.py deleted file mode 100755 index 288e7ff242..0000000000 --- a/maintenance/scripts/db_update_sem_dom_in_senses.py +++ /dev/null @@ -1,146 +0,0 @@ -#! /usr/bin/env python3 - -import argparse -import logging -from typing import Any, Dict, List, Optional - -from bson.binary import UuidRepresentation -from bson.codec_options import CodecOptions -from bson.objectid import ObjectId -from pymongo import MongoClient -from pymongo.collection import Collection - - -class SemanticDomainInfo: - def __init__(self, mongo_id: Optional[ObjectId], guid: str, name: str) -> None: - self.mongo_id = mongo_id - self.guid = guid - self.name = name - - -domain_info: Dict[str, List[SemanticDomainInfo]] = {} -blank_guid = "00000000-0000-0000-0000-000000000000" - - -def parse_args() -> argparse.Namespace: - """Define command line arguments for parser.""" - parser = argparse.ArgumentParser( - description="Restore TheCombine database and backend files from a file in AWS S3.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument("--host", default="localhost", help="Database hostname") - parser.add_argument("--port", "-p", default=27017, help="Database connection port") - parser.add_argument( - "--verbose", "-v", action="store_true", help="Print info while running script." - ) - return parser.parse_args() - - -def get_semantic_domain_info(collection: Collection[Dict[str, Any]]) -> None: - """ - Build a dictionary with pertinent Semantic Domain info. - - The dictionary key is the semantic domain id or abbreviation. - """ - - for sem_dom in collection.find({}): - mongo_id = sem_dom["_id"] - id = sem_dom["id"] - lang = sem_dom["lang"] - name = sem_dom["name"] - if not sem_dom["guid"]: - logging.warning(f"Using blank GUID for {id}, {lang}") - guid = blank_guid - else: - guid = str(sem_dom["guid"]) - info = SemanticDomainInfo(mongo_id, guid, name) - if id in domain_info: - domain_info[id].append(info) - else: - domain_info[id] = [info] - - -def get_domain_info(id: str, name: str) -> SemanticDomainInfo: - """Find the semantic domain info that matches the id and name.""" - if id in domain_info: - for info in domain_info[id]: - if name == info.name: - return info - logging.warning(f"Using blank GUID for {id} {name}") - return SemanticDomainInfo(None, blank_guid, name) - - -def is_obj_id(id: str) -> bool: - """Test if a string looks like a Mongo ObjectId.""" - try: - int(id, 16) - except ValueError: - return False - # Check the string length so that ids like 1, 2, etc. - # are not mistaken for Mongo Ids - if len(id) < 12: - return False - return True - - -def domain_needs_update(domain: Dict[str, Any]) -> bool: - """Test the domain to see if any parts of the old structure remain.""" - - # Test for keys that need to be removed - for key in ["Name", "Description"]: - if key in domain.keys(): - return True - if "_id" in domain.keys() and not is_obj_id(str(domain["_id"])): - return True - # Test for keys that need to be added - for key in ["id", "name", "guid", "lang"]: - if key not in domain.keys(): - return True - return False - - -def main() -> None: - args = parse_args() - - logging_level = logging.INFO if args.verbose else logging.WARNING - logging.basicConfig(format="%(levelname)s:%(message)s", level=logging_level) - - client: MongoClient[Dict[str, Any]] = MongoClient(args.host, args.port) - db = client.CombineDatabase - codec_opts: CodecOptions[Dict[str, Any]] = CodecOptions( - uuid_representation=UuidRepresentation.PYTHON_LEGACY - ) - semantic_domain_collection = db.get_collection("SemanticDomains", codec_options=codec_opts) - get_semantic_domain_info(semantic_domain_collection) - query: Dict[str, Any] = {"senses": {"$elemMatch": {"SemanticDomains": {"$ne": []}}}} - for collection_name in ["FrontierCollection", "WordsCollection"]: - updates: Dict[ObjectId, Any] = {} - curr_collection = db.get_collection(collection_name, codec_options=codec_opts) - num_docs = curr_collection.count_documents(query) - total_docs = curr_collection.count_documents({}) - logging.info(f"Checking {num_docs}/{total_docs} documents in {collection_name}.") - for word in curr_collection.find(query): - found_updates = False - for sense in word["senses"]: - if len(sense["SemanticDomains"]) > 0: - for domain in sense["SemanticDomains"]: - if domain_needs_update(domain): - domain["name"] = domain["Name"] - domain["id"] = domain["_id"] - this_domain = get_domain_info(domain["id"], domain["name"]) - domain["guid"] = this_domain.guid - domain["lang"] = "" - domain["_id"] = this_domain.mongo_id - domain.pop("Name", None) - domain.pop("Description", None) - found_updates = True - if found_updates: - updates[ObjectId(word["_id"])] = word - # apply the updates - logging.info(f"Updating {len(updates)}/{total_docs} documents in {collection_name}.") - for obj_id, update in updates.items(): - curr_collection.update_one({"_id": obj_id}, {"$set": update}) - - -if __name__ == "__main__": - main() diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0d3b04b610..5c4f5803e5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -79,6 +79,10 @@ "resetDone": "If you have correctly entered your email or username, a password reset link has been sent to your email address.", "backToLogin": "Back To Login" }, + "speakerMenu": { + "none": "No speakers in the project. To attach a speaker to audio recordings, please talk to a project administrator.", + "other": "[None of the above]" + }, "userMenu": { "siteSettings": "Site Settings", "userSettings": "User Settings", @@ -126,7 +130,8 @@ "phone": "Phone number", "uiLanguage": "User-interface language", "uiLanguageDefault": "(Default to browser language)", - "updateSuccess": "Settings successfully updated." + "updateSuccess": "Settings successfully updated.", + "uploadAvatarTitle": "Set user avatar" }, "projectExport": { "cannotExportEmpty": "Project is empty. You cannot export a project with no words.", @@ -197,6 +202,23 @@ "manageUser": "Manage User", "reverseOrder": "Reverse Order" }, + "speaker": { + "label": "Manage speakers", + "add": "Add a speaker", + "enterName": "Enter the name of a new speaker", + "delete": "Delete this speaker", + "edit": "Edit speaker's name", + "consent": { + "play": "Listen to the audio consent for this speaker", + "add": "Add consent for this speaker", + "record": "Record audio consent", + "view": "View this speaker's image consent", + "viewTitle": "Consent for speaker: {{ val }}", + "upload": "Upload image consent", + "remove": "Remove this speaker's consent", + "warning": "Warning: the speaker's current consent will be deleted--this cannot be undone." + } + }, "import": { "header": "Import Data", "body": "Imported data will be added to this project. The Combine will make no attempt to deduplicate, overwrite, or sync.", @@ -441,7 +463,13 @@ }, "pronunciations": { "recordTooltip": "Press and hold to record.", - "playTooltip": "Click to play; shift click to delete.", + "playTooltip": "Click to play.", + "deleteTooltip": "Shift click to delete.", + "protectedTooltip": "Imported audio cannot be deleted or modified.", + "speaker": "Speaker: {{ val }}.", + "speakerAdd": "Right click to add a speaker.", + "speakerChange": "Right click to change speaker.", + "speakerSelect": "Select speaker of this audio recording", "noMicAccess": "Recording error: Could not access a microphone.", "deleteRecording": "Delete Recording" }, diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index e11367889b..bc1bb21225 100644 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -36,6 +36,7 @@ def main() -> None: "--input-spec=http://localhost:5000/openapi/v1/openapi.json", f"--output={output_dir}", "--generator-name=typescript-axios", + "--reserved-words-mappings=protected=protected ", "--additional-properties=" # For usage of withSeparateModelsAndApi, see: # https://github.com/OpenAPITools/openapi-generator/issues/5008#issuecomment-613791804 diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index bb7a6ee30f..58f54943ea 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -10,6 +10,7 @@ api/lift-api.ts api/merge-api.ts api/project-api.ts api/semantic-domain-api.ts +api/speaker-api.ts api/statistics-api.ts api/user-api.ts api/user-edit-api.ts @@ -23,6 +24,7 @@ index.ts models/autocomplete-setting.ts models/banner-type.ts models/chart-root-data.ts +models/consent-type.ts models/credentials.ts models/custom-field.ts models/dataset.ts @@ -45,6 +47,7 @@ models/password-reset-request-data.ts models/permission.ts models/project-role.ts models/project.ts +models/pronunciation.ts models/role.ts models/semantic-domain-count.ts models/semantic-domain-full.ts @@ -53,6 +56,7 @@ models/semantic-domain-user-count.ts models/semantic-domain.ts models/sense.ts models/site-banner.ts +models/speaker.ts models/status.ts models/user-created-project.ts models/user-edit-step-wrapper.ts diff --git a/src/api/api.ts b/src/api/api.ts index 4e79e91082..745a7304de 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -20,6 +20,7 @@ export * from "./api/lift-api"; export * from "./api/merge-api"; export * from "./api/project-api"; export * from "./api/semantic-domain-api"; +export * from "./api/speaker-api"; export * from "./api/statistics-api"; export * from "./api/user-api"; export * from "./api/user-edit-api"; diff --git a/src/api/api/audio-api.ts b/src/api/api/audio-api.ts index 551e0eec7b..62e9438bfa 100644 --- a/src/api/api/audio-api.ts +++ b/src/api/api/audio-api.ts @@ -226,6 +226,90 @@ export const AudioApiAxiosParamCreator = function ( }; localVarRequestOptions.data = localVarFormParams; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} wordId + * @param {string} speakerId + * @param {any} file + * @param {string} name + * @param {string} filePath + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadAudioFileWithSpeaker: async ( + projectId: string, + wordId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("uploadAudioFileWithSpeaker", "projectId", projectId); + // verify required parameter 'wordId' is not null or undefined + assertParamExists("uploadAudioFileWithSpeaker", "wordId", wordId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("uploadAudioFileWithSpeaker", "speakerId", speakerId); + // verify required parameter 'file' is not null or undefined + assertParamExists("uploadAudioFileWithSpeaker", "file", file); + // verify required parameter 'name' is not null or undefined + assertParamExists("uploadAudioFileWithSpeaker", "name", name); + // verify required parameter 'filePath' is not null or undefined + assertParamExists("uploadAudioFileWithSpeaker", "filePath", filePath); + const localVarPath = + `/v1/projects/{projectId}/words/{wordId}/audio/upload/{speakerId}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"wordId"}}`, encodeURIComponent(String(wordId))) + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + const localVarFormParams = new ((configuration && + configuration.formDataCtor) || + FormData)(); + + if (file !== undefined) { + localVarFormParams.append("File", file as any); + } + + if (name !== undefined) { + localVarFormParams.append("Name", name as any); + } + + if (filePath !== undefined) { + localVarFormParams.append("FilePath", filePath as any); + } + + localVarHeaderParameter["Content-Type"] = "multipart/form-data"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = localVarFormParams; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -335,6 +419,45 @@ export const AudioApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} projectId + * @param {string} wordId + * @param {string} speakerId + * @param {any} file + * @param {string} name + * @param {string} filePath + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async uploadAudioFileWithSpeaker( + projectId: string, + wordId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.uploadAudioFileWithSpeaker( + projectId, + wordId, + speakerId, + file, + name, + filePath, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -407,6 +530,38 @@ export const AudioApiFactory = function ( .uploadAudioFile(projectId, wordId, file, name, filePath, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} projectId + * @param {string} wordId + * @param {string} speakerId + * @param {any} file + * @param {string} name + * @param {string} filePath + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadAudioFileWithSpeaker( + projectId: string, + wordId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): AxiosPromise { + return localVarFp + .uploadAudioFileWithSpeaker( + projectId, + wordId, + speakerId, + file, + name, + filePath, + options + ) + .then((request) => request(axios, basePath)); + }, }; }; @@ -508,6 +663,55 @@ export interface AudioApiUploadAudioFileRequest { readonly filePath: string; } +/** + * Request parameters for uploadAudioFileWithSpeaker operation in AudioApi. + * @export + * @interface AudioApiUploadAudioFileWithSpeakerRequest + */ +export interface AudioApiUploadAudioFileWithSpeakerRequest { + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFileWithSpeaker + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFileWithSpeaker + */ + readonly wordId: string; + + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFileWithSpeaker + */ + readonly speakerId: string; + + /** + * + * @type {any} + * @memberof AudioApiUploadAudioFileWithSpeaker + */ + readonly file: any; + + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFileWithSpeaker + */ + readonly name: string; + + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFileWithSpeaker + */ + readonly filePath: string; +} + /** * AudioApi - object-oriented interface * @export @@ -579,4 +783,28 @@ export class AudioApi extends BaseAPI { ) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {AudioApiUploadAudioFileWithSpeakerRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AudioApi + */ + public uploadAudioFileWithSpeaker( + requestParameters: AudioApiUploadAudioFileWithSpeakerRequest, + options?: any + ) { + return AudioApiFp(this.configuration) + .uploadAudioFileWithSpeaker( + requestParameters.projectId, + requestParameters.wordId, + requestParameters.speakerId, + requestParameters.file, + requestParameters.name, + requestParameters.filePath, + options + ) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/api/api/speaker-api.ts b/src/api/api/speaker-api.ts new file mode 100644 index 0000000000..e987fd43eb --- /dev/null +++ b/src/api/api/speaker-api.ts @@ -0,0 +1,1317 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import globalAxios, { AxiosPromise, AxiosInstance } from "axios"; +import { Configuration } from "../configuration"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +// @ts-ignore +import { Speaker } from "../models"; +/** + * SpeakerApi - axios parameter creator + * @export + */ +export const SpeakerApiAxiosParamCreator = function ( + configuration?: Configuration +) { + return { + /** + * + * @param {string} name + * @param {string} [projectId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createSpeaker: async ( + name: string, + projectId?: string, + options: any = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("createSpeaker", "name", name); + const localVarPath = `/create/{name}`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (projectId !== undefined) { + localVarQueryParameter["projectId"] = projectId; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteProjectSpeakers: async ( + projectId: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("deleteProjectSpeakers", "projectId", projectId); + const localVarPath = `/v1/projects/{projectId}/speakers`.replace( + `{${"projectId"}}`, + encodeURIComponent(String(projectId)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteSpeaker: async ( + projectId: string, + speakerId: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("deleteSpeaker", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("deleteSpeaker", "speakerId", speakerId); + const localVarPath = `/v1/projects/{projectId}/speakers/{speakerId}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} speakerId + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadConsent: async ( + speakerId: string, + projectId: string, + options: any = {} + ): Promise => { + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("downloadConsent", "speakerId", speakerId); + // verify required parameter 'projectId' is not null or undefined + assertParamExists("downloadConsent", "projectId", projectId); + const localVarPath = + `/v1/projects/{projectId}/speakers/consent/{speakerId}` + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))) + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProjectSpeakers: async ( + projectId: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("getProjectSpeakers", "projectId", projectId); + const localVarPath = `/v1/projects/{projectId}/speakers`.replace( + `{${"projectId"}}`, + encodeURIComponent(String(projectId)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSpeaker: async ( + projectId: string, + speakerId: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("getSpeaker", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("getSpeaker", "speakerId", speakerId); + const localVarPath = `/v1/projects/{projectId}/speakers/{speakerId}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeConsent: async ( + projectId: string, + speakerId: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("removeConsent", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("removeConsent", "speakerId", speakerId); + const localVarPath = + `/v1/projects/{projectId}/speakers/consent/{speakerId}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateSpeakerName: async ( + projectId: string, + speakerId: string, + name: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("updateSpeakerName", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("updateSpeakerName", "speakerId", speakerId); + // verify required parameter 'name' is not null or undefined + assertParamExists("updateSpeakerName", "name", name); + const localVarPath = + `/v1/projects/{projectId}/speakers/update/{speakerId}/{name}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))) + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {any} file + * @param {string} name + * @param {string} filePath + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadConsent: async ( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("uploadConsent", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("uploadConsent", "speakerId", speakerId); + // verify required parameter 'file' is not null or undefined + assertParamExists("uploadConsent", "file", file); + // verify required parameter 'name' is not null or undefined + assertParamExists("uploadConsent", "name", name); + // verify required parameter 'filePath' is not null or undefined + assertParamExists("uploadConsent", "filePath", filePath); + const localVarPath = + `/v1/projects/{projectId}/speakers/consent/{speakerId}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + const localVarFormParams = new ((configuration && + configuration.formDataCtor) || + FormData)(); + + if (file !== undefined) { + localVarFormParams.append("File", file as any); + } + + if (name !== undefined) { + localVarFormParams.append("Name", name as any); + } + + if (filePath !== undefined) { + localVarFormParams.append("FilePath", filePath as any); + } + + localVarHeaderParameter["Content-Type"] = "multipart/form-data"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = localVarFormParams; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; +}; + +/** + * SpeakerApi - functional programming interface + * @export + */ +export const SpeakerApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = SpeakerApiAxiosParamCreator(configuration); + return { + /** + * + * @param {string} name + * @param {string} [projectId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createSpeaker( + name: string, + projectId?: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.createSpeaker( + name, + projectId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteProjectSpeakers( + projectId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.deleteProjectSpeakers( + projectId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteSpeaker( + projectId: string, + speakerId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSpeaker( + projectId, + speakerId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} speakerId + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadConsent( + speakerId: string, + projectId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadConsent( + speakerId, + projectId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getProjectSpeakers( + projectId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getProjectSpeakers(projectId, options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSpeaker( + projectId: string, + speakerId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSpeaker( + projectId, + speakerId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async removeConsent( + projectId: string, + speakerId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeConsent( + projectId, + speakerId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateSpeakerName( + projectId: string, + speakerId: string, + name: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.updateSpeakerName( + projectId, + speakerId, + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {any} file + * @param {string} name + * @param {string} filePath + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async uploadConsent( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadConsent( + projectId, + speakerId, + file, + name, + filePath, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; +}; + +/** + * SpeakerApi - factory interface + * @export + */ +export const SpeakerApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = SpeakerApiFp(configuration); + return { + /** + * + * @param {string} name + * @param {string} [projectId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createSpeaker( + name: string, + projectId?: string, + options?: any + ): AxiosPromise { + return localVarFp + .createSpeaker(name, projectId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteProjectSpeakers( + projectId: string, + options?: any + ): AxiosPromise { + return localVarFp + .deleteProjectSpeakers(projectId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteSpeaker( + projectId: string, + speakerId: string, + options?: any + ): AxiosPromise { + return localVarFp + .deleteSpeaker(projectId, speakerId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} speakerId + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadConsent( + speakerId: string, + projectId: string, + options?: any + ): AxiosPromise { + return localVarFp + .downloadConsent(speakerId, projectId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProjectSpeakers( + projectId: string, + options?: any + ): AxiosPromise> { + return localVarFp + .getProjectSpeakers(projectId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSpeaker( + projectId: string, + speakerId: string, + options?: any + ): AxiosPromise { + return localVarFp + .getSpeaker(projectId, speakerId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeConsent( + projectId: string, + speakerId: string, + options?: any + ): AxiosPromise { + return localVarFp + .removeConsent(projectId, speakerId, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateSpeakerName( + projectId: string, + speakerId: string, + name: string, + options?: any + ): AxiosPromise { + return localVarFp + .updateSpeakerName(projectId, speakerId, name, options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} projectId + * @param {string} speakerId + * @param {any} file + * @param {string} name + * @param {string} filePath + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadConsent( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): AxiosPromise { + return localVarFp + .uploadConsent(projectId, speakerId, file, name, filePath, options) + .then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createSpeaker operation in SpeakerApi. + * @export + * @interface SpeakerApiCreateSpeakerRequest + */ +export interface SpeakerApiCreateSpeakerRequest { + /** + * + * @type {string} + * @memberof SpeakerApiCreateSpeaker + */ + readonly name: string; + + /** + * + * @type {string} + * @memberof SpeakerApiCreateSpeaker + */ + readonly projectId?: string; +} + +/** + * Request parameters for deleteProjectSpeakers operation in SpeakerApi. + * @export + * @interface SpeakerApiDeleteProjectSpeakersRequest + */ +export interface SpeakerApiDeleteProjectSpeakersRequest { + /** + * + * @type {string} + * @memberof SpeakerApiDeleteProjectSpeakers + */ + readonly projectId: string; +} + +/** + * Request parameters for deleteSpeaker operation in SpeakerApi. + * @export + * @interface SpeakerApiDeleteSpeakerRequest + */ +export interface SpeakerApiDeleteSpeakerRequest { + /** + * + * @type {string} + * @memberof SpeakerApiDeleteSpeaker + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiDeleteSpeaker + */ + readonly speakerId: string; +} + +/** + * Request parameters for downloadConsent operation in SpeakerApi. + * @export + * @interface SpeakerApiDownloadConsentRequest + */ +export interface SpeakerApiDownloadConsentRequest { + /** + * + * @type {string} + * @memberof SpeakerApiDownloadConsent + */ + readonly speakerId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiDownloadConsent + */ + readonly projectId: string; +} + +/** + * Request parameters for getProjectSpeakers operation in SpeakerApi. + * @export + * @interface SpeakerApiGetProjectSpeakersRequest + */ +export interface SpeakerApiGetProjectSpeakersRequest { + /** + * + * @type {string} + * @memberof SpeakerApiGetProjectSpeakers + */ + readonly projectId: string; +} + +/** + * Request parameters for getSpeaker operation in SpeakerApi. + * @export + * @interface SpeakerApiGetSpeakerRequest + */ +export interface SpeakerApiGetSpeakerRequest { + /** + * + * @type {string} + * @memberof SpeakerApiGetSpeaker + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiGetSpeaker + */ + readonly speakerId: string; +} + +/** + * Request parameters for removeConsent operation in SpeakerApi. + * @export + * @interface SpeakerApiRemoveConsentRequest + */ +export interface SpeakerApiRemoveConsentRequest { + /** + * + * @type {string} + * @memberof SpeakerApiRemoveConsent + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiRemoveConsent + */ + readonly speakerId: string; +} + +/** + * Request parameters for updateSpeakerName operation in SpeakerApi. + * @export + * @interface SpeakerApiUpdateSpeakerNameRequest + */ +export interface SpeakerApiUpdateSpeakerNameRequest { + /** + * + * @type {string} + * @memberof SpeakerApiUpdateSpeakerName + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUpdateSpeakerName + */ + readonly speakerId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUpdateSpeakerName + */ + readonly name: string; +} + +/** + * Request parameters for uploadConsent operation in SpeakerApi. + * @export + * @interface SpeakerApiUploadConsentRequest + */ +export interface SpeakerApiUploadConsentRequest { + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsent + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsent + */ + readonly speakerId: string; + + /** + * + * @type {any} + * @memberof SpeakerApiUploadConsent + */ + readonly file: any; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsent + */ + readonly name: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsent + */ + readonly filePath: string; +} + +/** + * SpeakerApi - object-oriented interface + * @export + * @class SpeakerApi + * @extends {BaseAPI} + */ +export class SpeakerApi extends BaseAPI { + /** + * + * @param {SpeakerApiCreateSpeakerRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public createSpeaker( + requestParameters: SpeakerApiCreateSpeakerRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .createSpeaker( + requestParameters.name, + requestParameters.projectId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiDeleteProjectSpeakersRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public deleteProjectSpeakers( + requestParameters: SpeakerApiDeleteProjectSpeakersRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .deleteProjectSpeakers(requestParameters.projectId, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiDeleteSpeakerRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public deleteSpeaker( + requestParameters: SpeakerApiDeleteSpeakerRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .deleteSpeaker( + requestParameters.projectId, + requestParameters.speakerId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiDownloadConsentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public downloadConsent( + requestParameters: SpeakerApiDownloadConsentRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .downloadConsent( + requestParameters.speakerId, + requestParameters.projectId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiGetProjectSpeakersRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public getProjectSpeakers( + requestParameters: SpeakerApiGetProjectSpeakersRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .getProjectSpeakers(requestParameters.projectId, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiGetSpeakerRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public getSpeaker( + requestParameters: SpeakerApiGetSpeakerRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .getSpeaker( + requestParameters.projectId, + requestParameters.speakerId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiRemoveConsentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public removeConsent( + requestParameters: SpeakerApiRemoveConsentRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .removeConsent( + requestParameters.projectId, + requestParameters.speakerId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiUpdateSpeakerNameRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public updateSpeakerName( + requestParameters: SpeakerApiUpdateSpeakerNameRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .updateSpeakerName( + requestParameters.projectId, + requestParameters.speakerId, + requestParameters.name, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SpeakerApiUploadConsentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public uploadConsent( + requestParameters: SpeakerApiUploadConsentRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .uploadConsent( + requestParameters.projectId, + requestParameters.speakerId, + requestParameters.file, + requestParameters.name, + requestParameters.filePath, + options + ) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/src/api/models/consent-type.ts b/src/api/models/consent-type.ts new file mode 100644 index 0000000000..450a6e3c9e --- /dev/null +++ b/src/api/models/consent-type.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @enum {string} + */ +export enum ConsentType { + None = "None", + Audio = "Audio", + Image = "Image", +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 84464055aa..33539e6fa0 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,6 +1,7 @@ export * from "./autocomplete-setting"; export * from "./banner-type"; export * from "./chart-root-data"; +export * from "./consent-type"; export * from "./credentials"; export * from "./custom-field"; export * from "./dataset"; @@ -22,6 +23,7 @@ export * from "./password-reset-request-data"; export * from "./permission"; export * from "./project"; export * from "./project-role"; +export * from "./pronunciation"; export * from "./role"; export * from "./semantic-domain"; export * from "./semantic-domain-count"; @@ -30,6 +32,7 @@ export * from "./semantic-domain-tree-node"; export * from "./semantic-domain-user-count"; export * from "./sense"; export * from "./site-banner"; +export * from "./speaker"; export * from "./status"; export * from "./user"; export * from "./user-created-project"; diff --git a/src/api/models/pronunciation.ts b/src/api/models/pronunciation.ts new file mode 100644 index 0000000000..50657c2cc1 --- /dev/null +++ b/src/api/models/pronunciation.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface Pronunciation + */ +export interface Pronunciation { + /** + * + * @type {string} + * @memberof Pronunciation + */ + fileName: string; + /** + * + * @type {string} + * @memberof Pronunciation + */ + speakerId: string; + /** + * + * @type {boolean} + * @memberof Pronunciation + */ + protected: boolean; +} diff --git a/src/api/models/speaker.ts b/src/api/models/speaker.ts new file mode 100644 index 0000000000..5711fc75d1 --- /dev/null +++ b/src/api/models/speaker.ts @@ -0,0 +1,47 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ConsentType } from "./consent-type"; + +/** + * + * @export + * @interface Speaker + */ +export interface Speaker { + /** + * + * @type {string} + * @memberof Speaker + */ + id: string; + /** + * + * @type {string} + * @memberof Speaker + */ + projectId: string; + /** + * + * @type {string} + * @memberof Speaker + */ + name: string; + /** + * + * @type {ConsentType} + * @memberof Speaker + */ + consent: ConsentType; +} diff --git a/src/api/models/word.ts b/src/api/models/word.ts index d8bf0d04a1..94b4e41c1c 100644 --- a/src/api/models/word.ts +++ b/src/api/models/word.ts @@ -14,6 +14,7 @@ import { Flag } from "./flag"; import { Note } from "./note"; +import { Pronunciation } from "./pronunciation"; import { Sense } from "./sense"; import { Status } from "./status"; @@ -55,10 +56,10 @@ export interface Word { senses: Array; /** * - * @type {Array} + * @type {Array} * @memberof Word */ - audio: Array; + audio: Array; /** * * @type {string} diff --git a/src/backend/index.ts b/src/backend/index.ts index 89ace276bf..373019ed22 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -19,6 +19,7 @@ import { SemanticDomainTreeNode, SemanticDomainUserCount, SiteBanner, + Speaker, User, UserEdit, UserRole, @@ -30,6 +31,7 @@ import authHeader from "components/Login/AuthHeaders"; import { Goal, GoalStep } from "types/goals"; import { Path } from "types/path"; import { RuntimeConfig } from "types/runtimeConfig"; +import { FileWithSpeakerId } from "types/word"; import { Bcp47Code } from "types/writingSystem"; import { convertGoalToEdit } from "utilities/goalUtilities"; @@ -101,6 +103,7 @@ const semanticDomainApi = new Api.SemanticDomainApi( BASE_PATH, axiosInstance ); +const speakerApi = new Api.SpeakerApi(config, BASE_PATH, axiosInstance); const statisticsApi = new Api.StatisticsApi(config, BASE_PATH, axiosInstance); const userApi = new Api.UserApi(config, BASE_PATH, axiosInstance); const userEditApi = new Api.UserEditApi(config, BASE_PATH, axiosInstance); @@ -122,14 +125,16 @@ function defaultOptions(): object { export async function uploadAudio( wordId: string, - audioFile: File + file: FileWithSpeakerId ): Promise { const projectId = LocalStorage.getProjectId(); - const resp = await audioApi.uploadAudioFile( - { projectId, wordId, ...fileUpload(audioFile) }, - { headers: { ...authHeader(), "content-type": "application/json" } } - ); - return resp.data; + const speakerId = file.speakerId ?? ""; + const params = { projectId, wordId, ...fileUpload(file) }; + const headers = { ...authHeader(), "content-type": "application/json" }; + const promise = speakerId + ? audioApi.uploadAudioFileWithSpeaker({ ...params, speakerId }, { headers }) + : audioApi.uploadAudioFile(params, { headers }); + return (await promise).data; } export async function deleteAudio( @@ -459,6 +464,99 @@ export async function getSemanticDomainTreeNodeByName( return response.data ?? undefined; } +/* SpeakerController.cs */ + +/** Get all speakers (in current project if no projectId given). + * Returns array of speakers, sorted alphabetically by name. */ +export async function getAllSpeakers(projectId?: string): Promise { + const params = { projectId: projectId || LocalStorage.getProjectId() }; + const resp = await speakerApi.getProjectSpeakers(params, defaultOptions()); + return resp.data.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Get speaker by speakerId (in current project if no projectId given). */ +export async function getSpeaker( + speakerId: string, + projectId?: string +): Promise { + projectId = projectId || LocalStorage.getProjectId(); + const params = { projectId, speakerId }; + return (await speakerApi.getSpeaker(params, defaultOptions())).data; +} + +/** Creates new speaker (in current project if no projectId given). + * Returns id of new speaker. */ +export async function createSpeaker( + name: string, + projectId?: string +): Promise { + projectId = projectId || LocalStorage.getProjectId(); + const params = { name, projectId }; + return (await speakerApi.createSpeaker(params, defaultOptions())).data; +} + +/** Delete specified speaker (in current project if no projectId given). + * Returns boolean of success. */ +export async function deleteSpeaker( + speakerId: string, + projectId?: string +): Promise { + projectId = projectId || LocalStorage.getProjectId(); + const params = { projectId, speakerId }; + return (await speakerApi.deleteSpeaker(params, defaultOptions())).data; +} + +/** Remove consent of specified speaker (in current project if no projectId given). + * Returns id of updated speaker. */ +export async function removeConsent(speaker: Speaker): Promise { + const projectId = speaker.projectId || LocalStorage.getProjectId(); + const params = { projectId, speakerId: speaker.id }; + return (await speakerApi.removeConsent(params, defaultOptions())).data; +} + +/** Updates name of specified speaker (in current project if no projectId given). + * Returns id of updated speaker. */ +export async function updateSpeakerName( + speakerId: string, + name: string, + projectId?: string +): Promise { + projectId = projectId || LocalStorage.getProjectId(); + const params = { name, projectId, speakerId }; + return (await speakerApi.updateSpeakerName(params, defaultOptions())).data; +} + +/** Uploads consent for specified speaker; overwrites previous consent. + * Returns updated speaker. */ +export async function uploadConsent( + speaker: Speaker, + file: File +): Promise { + const { id, projectId } = speaker; + const params = { projectId, speakerId: id, ...fileUpload(file) }; + const headers = { ...authHeader(), "content-type": "application/json" }; + return (await speakerApi.uploadConsent(params, { headers })).data; +} + +/** Use of the returned url acts as an HttpGet. */ +export function getConsentUrl(speaker: Speaker): string { + return `${apiBaseURL}/projects/${speaker.projectId}/speakers/consent/${speaker.id}`; +} + +/** Returns the string to display the image inline in Base64 { + const params = { projectId: speaker.projectId, speakerId: speaker.id }; + const options = { headers: authHeader(), responseType: "arraybuffer" }; + const resp = await speakerApi.downloadConsent(params, options); + const image = Base64.btoa( + new Uint8Array(resp.data).reduce( + (data, byte) => data + String.fromCharCode(byte), + "" + ) + ); + return `data:${resp.headers["content-type"].toLowerCase()};base64,${image}`; +} + /* StatisticsController.cs */ export async function getSemanticDomainCounts( diff --git a/src/components/AppBar/ProjectButtons.tsx b/src/components/AppBar/ProjectButtons.tsx index dae48b2ee1..95156f5db1 100644 --- a/src/components/AppBar/ProjectButtons.tsx +++ b/src/components/AppBar/ProjectButtons.tsx @@ -13,7 +13,9 @@ import { shortenName, tabColor, } from "components/AppBar/AppBarTypes"; +import SpeakerMenu from "components/AppBar/SpeakerMenu"; import { StoreState } from "types"; +import { GoalStatus, GoalType } from "types/goals"; import { Path } from "types/path"; export const projButtonId = "project-settings"; @@ -30,7 +32,13 @@ export default function ProjectButtons(props: TabProps): ReactElement { const projectName = useSelector( (state: StoreState) => state.currentProjectState.project.name ); - const [hasStatsPermission, setHasStatsPermission] = useState(false); + const showSpeaker = useSelector( + (state: StoreState) => + Path.DataEntry === props.currentTab || + (state.goalsState.currentGoal.goalType === GoalType.ReviewEntries && + state.goalsState.currentGoal.status === GoalStatus.InProgress) + ); + const [hasStatsPermission, setHasStatsPermission] = useState(false); const { t } = useTranslation(); const navigate = useNavigate(); @@ -87,6 +95,7 @@ export default function ProjectButtons(props: TabProps): ReactElement { + {showSpeaker && } ); } diff --git a/src/components/AppBar/SpeakerMenu.tsx b/src/components/AppBar/SpeakerMenu.tsx new file mode 100644 index 0000000000..5b7a434794 --- /dev/null +++ b/src/components/AppBar/SpeakerMenu.tsx @@ -0,0 +1,138 @@ +import { Circle, RecordVoiceOver } from "@mui/icons-material"; +import { + Button, + Divider, + Icon, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Typography, +} from "@mui/material"; +import { + ForwardedRef, + MouseEvent, + ReactElement, + useEffect, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; + +import { Speaker } from "api"; +import { getAllSpeakers } from "backend"; +import { buttonMinHeight } from "components/AppBar/AppBarTypes"; +import { setCurrentSpeaker } from "components/Project/ProjectActions"; +import { StoreState } from "types"; +import { useAppDispatch } from "types/hooks"; +import { themeColors } from "types/theme"; + +const idAffix = "speaker-menu"; + +/** Icon with dropdown SpeakerMenu */ +export default function SpeakerMenu(): ReactElement { + const dispatch = useAppDispatch(); + const currentSpeaker = useSelector( + (state: StoreState) => state.currentProjectState.speaker + ); + const [anchorElement, setAnchorElement] = useState(); + + function handleClick(event: MouseEvent): void { + setAnchorElement(event.currentTarget); + } + + function handleClose(): void { + setAnchorElement(undefined); + } + + return ( + <> + + + dispatch(setCurrentSpeaker(speaker))} + selectedId={currentSpeaker?.id} + /> + + + ); +} + +interface SpeakerMenuListProps { + forwardedRef?: ForwardedRef; + onSelect: (speaker?: Speaker) => void; + selectedId?: string; +} + +/** SpeakerMenu options */ +export function SpeakerMenuList(props: SpeakerMenuListProps): ReactElement { + const projectId = useSelector( + (state: StoreState) => state.currentProjectState.project.id + ); + const [speakers, setSpeakers] = useState([]); + const { t } = useTranslation(); + + useEffect(() => { + if (projectId) { + getAllSpeakers(projectId).then(setSpeakers); + } + }, [projectId]); + + const currentIcon = ( + + ); + + const speakerMenuItem = (speaker?: Speaker): ReactElement => { + const isCurrent = speaker?.id === props.selectedId; + return ( + (isCurrent ? {} : props.onSelect(speaker))} + > + {isCurrent ? currentIcon : } + {speaker?.name ?? t("speakerMenu.other")} + + ); + }; + + return ( +
+ {speakers.length ? ( + <> + {speakers.map((s) => speakerMenuItem(s))} + + {speakerMenuItem()} + + ) : ( + + + {t("speakerMenu.none")} + + + )} +
+ ); +} diff --git a/src/components/AppBar/tests/ProjectButtons.test.tsx b/src/components/AppBar/tests/ProjectButtons.test.tsx index d97bd073c7..b332c90324 100644 --- a/src/components/AppBar/tests/ProjectButtons.test.tsx +++ b/src/components/AppBar/tests/ProjectButtons.test.tsx @@ -1,7 +1,7 @@ import { Button } from "@mui/material"; import { Provider } from "react-redux"; import { ReactTestRenderer, act, create } from "react-test-renderer"; -import configureMockStore from "redux-mock-store"; +import configureMockStore, { MockStoreEnhanced } from "redux-mock-store"; import "tests/reactI18nextMock"; @@ -10,6 +10,11 @@ import ProjectButtons, { projButtonId, statButtonId, } from "components/AppBar/ProjectButtons"; +import SpeakerMenu from "components/AppBar/SpeakerMenu"; +import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; +import { MergeDups } from "goals/MergeDuplicates/MergeDupsTypes"; +import { ReviewEntries } from "goals/ReviewEntries/ReviewEntriesTypes"; +import { Goal, GoalStatus } from "types/goals"; import { Path } from "types/path"; import { themeColors } from "types/theme"; @@ -29,14 +34,19 @@ mockProjectRoles[mockProjectId] = "non-empty-string"; let testRenderer: ReactTestRenderer; -const mockStore = configureMockStore()({ - currentProjectState: { project: { name: "" } }, -}); +const mockStore = (goal?: Goal): MockStoreEnhanced => + configureMockStore()({ + currentProjectState, + goalsState: { currentGoal: goal ?? new Goal() }, + }); -const renderProjectButtons = async (path = Path.Root): Promise => { +const renderProjectButtons = async ( + path = Path.Root, + goal?: Goal +): Promise => { await act(async () => { testRenderer = create( - + ); @@ -54,12 +64,33 @@ describe("ProjectButtons", () => { expect(testRenderer.root.findAllByType(Button)).toHaveLength(1); }); - it("has second button for admin or project owner", async () => { + it("has another button for admin or project owner", async () => { mockHasPermission.mockResolvedValueOnce(true); await renderProjectButtons(); expect(testRenderer.root.findAllByType(Button)).toHaveLength(2); }); + it("has speaker menu only when in Data Entry or Review Entries", async () => { + await renderProjectButtons(); + expect(testRenderer.root.findAllByType(SpeakerMenu)).toHaveLength(0); + + await renderProjectButtons(Path.DataEntry); + expect(testRenderer.root.findAllByType(SpeakerMenu)).toHaveLength(1); + + let currentGoal: Goal; + currentGoal = { ...new MergeDups(), status: GoalStatus.InProgress }; + await renderProjectButtons(Path.GoalCurrent, currentGoal); + expect(testRenderer.root.findAllByType(SpeakerMenu)).toHaveLength(0); + + currentGoal = { ...new ReviewEntries(), status: GoalStatus.Completed }; + await renderProjectButtons(Path.GoalCurrent, currentGoal); + expect(testRenderer.root.findAllByType(SpeakerMenu)).toHaveLength(0); + + currentGoal = { ...new ReviewEntries(), status: GoalStatus.InProgress }; + await renderProjectButtons(Path.GoalCurrent, currentGoal); + expect(testRenderer.root.findAllByType(SpeakerMenu)).toHaveLength(1); + }); + it("has settings tab shaded correctly", async () => { await renderProjectButtons(); let button = testRenderer.root.findByProps({ id: projButtonId }); diff --git a/src/components/AppBar/tests/SpeakerMenu.test.tsx b/src/components/AppBar/tests/SpeakerMenu.test.tsx new file mode 100644 index 0000000000..f1f19ed782 --- /dev/null +++ b/src/components/AppBar/tests/SpeakerMenu.test.tsx @@ -0,0 +1,101 @@ +import { Circle } from "@mui/icons-material"; +import { Button, Divider, MenuItem } from "@mui/material"; +import { Provider } from "react-redux"; +import { act, create, ReactTestRenderer } from "react-test-renderer"; +import configureMockStore from "redux-mock-store"; + +import "tests/reactI18nextMock"; + +import { Speaker } from "api/models"; +import SpeakerMenu, { SpeakerMenuList } from "components/AppBar/SpeakerMenu"; +import { defaultState } from "components/Project/ProjectReduxTypes"; +import { StoreState } from "types"; +import { randomSpeaker } from "types/project"; + +jest.mock("backend", () => ({ + getAllSpeakers: () => mockGetAllSpeakers(), +})); + +const mockProjId = "mock-project-id"; +const mockGetAllSpeakers = jest.fn(); +const mockState = (speaker?: Speaker): Partial => ({ + currentProjectState: { + ...defaultState, + speaker: speaker ?? defaultState.speaker, + project: { ...defaultState.project, id: mockProjId }, + }, +}); + +function setMockFunctions(): void { + mockGetAllSpeakers.mockResolvedValue([]); +} + +let testRenderer: ReactTestRenderer; + +beforeEach(() => { + jest.clearAllMocks(); + setMockFunctions(); +}); + +describe("SpeakerMenu", () => { + it("renders", async () => { + await act(async () => { + testRenderer = create( + + + + ); + }); + expect(testRenderer.root.findAllByType(Button).length).toEqual(1); + }); +}); + +describe("SpeakerMenuList", () => { + it("has one disabled menu item if no speakers", async () => { + await renderMenuList(); + const menuItem = testRenderer.root.findByType(MenuItem); + expect(menuItem).toBeDisabled; + }); + + it("has divider and one more menu item than speakers", async () => { + const speakers = [randomSpeaker(), randomSpeaker(), randomSpeaker()]; + mockGetAllSpeakers.mockResolvedValueOnce(speakers); + await renderMenuList(); + testRenderer.root.findByType(Divider); + const menuItems = testRenderer.root.findAllByType(MenuItem); + expect(menuItems).toHaveLength(speakers.length + 1); + }); + + it("current speaker marked", async () => { + const currentSpeaker = randomSpeaker(); + const speakers = [randomSpeaker(), currentSpeaker, randomSpeaker()]; + mockGetAllSpeakers.mockResolvedValueOnce(speakers); + await renderMenuList(currentSpeaker.id); + const items = testRenderer.root.findAllByType(MenuItem); + for (let i = 0; i < items.length; i++) { + expect(items[i].findAllByType(Circle)).toHaveLength(i === 1 ? 1 : 0); + } + }); + + it("none-of-the-above marked", async () => { + const speakers = [randomSpeaker(), randomSpeaker(), randomSpeaker()]; + mockGetAllSpeakers.mockResolvedValueOnce(speakers); + await renderMenuList(); + const items = testRenderer.root.findAllByType(MenuItem); + for (let i = 0; i < items.length; i++) { + expect(items[i].findAllByType(Circle)).toHaveLength( + i === items.length - 1 ? 1 : 0 + ); + } + }); +}); + +async function renderMenuList(selectedId?: string): Promise { + await act(async () => { + testRenderer = create( + + + + ); + }); +} diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index 0e657ea901..fb8d2b1556 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx @@ -11,7 +11,7 @@ import { import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; -import { Word, WritingSystem } from "api/models"; +import { Pronunciation, Word, WritingSystem } from "api/models"; import { focusInput } from "components/DataEntry/DataEntryTable"; import { DeleteEntry, @@ -24,6 +24,7 @@ import VernDialog from "components/DataEntry/DataEntryTable/NewEntry/VernDialog" import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; import { StoreState } from "types"; import theme from "types/theme"; +import { FileWithSpeakerId } from "types/word"; const idAffix = "new-entry"; @@ -45,9 +46,10 @@ interface NewEntryProps { addNewEntry: () => Promise; resetNewEntry: () => void; updateWordWithNewGloss: (wordId: string) => Promise; - newAudioUrls: string[]; - addNewAudioUrl: (file: File) => void; - delNewAudioUrl: (url: string) => void; + newAudio: Pronunciation[]; + addNewAudio: (file: FileWithSpeakerId) => void; + delNewAudio: (url: string) => void; + repNewAudio: (audio: Pronunciation) => void; newGloss: string; setNewGloss: (gloss: string) => void; newNote: string; @@ -73,9 +75,10 @@ export default function NewEntry(props: NewEntryProps): ReactElement { addNewEntry, resetNewEntry, updateWordWithNewGloss, - newAudioUrls, - addNewAudioUrl, - delNewAudioUrl, + newAudio, + addNewAudio, + delNewAudio, + repNewAudio, newGloss, setNewGloss, newNote, @@ -291,9 +294,10 @@ export default function NewEntry(props: NewEntryProps): ReactElement { focus(FocusTarget.Gloss)} /> diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx index 54d410dc0e..3f98cd71c1 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -11,7 +11,6 @@ import { newWritingSystem } from "types/writingSystem"; jest.mock("@mui/material/Autocomplete", () => "div"); jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); -jest.mock("components/Pronunciations/Recorder"); const mockStore = configureMockStore()({ treeViewState: { open: false } }); @@ -27,9 +26,10 @@ describe("NewEntry", () => { addNewEntry={jest.fn()} resetNewEntry={jest.fn()} updateWordWithNewGloss={jest.fn()} - newAudioUrls={[]} - addNewAudioUrl={jest.fn()} - delNewAudioUrl={jest.fn()} + newAudio={[]} + addNewAudio={jest.fn()} + delNewAudio={jest.fn()} + repNewAudio={jest.fn()} newGloss={""} setNewGloss={jest.fn()} newNote={""} diff --git a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx index de27a2b4d3..95264f1f85 100644 --- a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx @@ -1,7 +1,7 @@ import { Grid } from "@mui/material"; import { ReactElement, memo, useState } from "react"; -import { Word, WritingSystem } from "api/models"; +import { Pronunciation, Word, WritingSystem } from "api/models"; import { DeleteEntry, EntryNote, @@ -10,7 +10,7 @@ import { } from "components/DataEntry/DataEntryTable/EntryCellComponents"; import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; import theme from "types/theme"; -import { newGloss } from "types/word"; +import { FileWithSpeakerId, newGloss } from "types/word"; import { firstGlossText } from "utilities/wordUtilities"; const idAffix = "recent-entry"; @@ -23,8 +23,9 @@ export interface RecentEntryProps { updateNote: (index: number, newText: string) => Promise; updateVern: (index: number, newVern: string, targetWordId?: string) => void; removeEntry: (index: number) => void; - addAudioToWord: (wordId: string, audioFile: File) => void; - deleteAudioFromWord: (wordId: string, fileName: string) => void; + addAudioToWord: (wordId: string, file: FileWithSpeakerId) => void; + delAudioFromWord: (wordId: string, fileName: string) => void; + repAudioInWord: (wordId: string, audio: Pronunciation) => void; focusNewEntry: () => void; analysisLang: WritingSystem; vernacularLang: WritingSystem; @@ -134,13 +135,16 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { > {!props.disabled && ( { - props.deleteAudioFromWord(props.entry.id, fileName); + deleteAudio={(fileName) => { + props.delAudioFromWord(props.entry.id, fileName); }} - uploadAudio={(audioFile: File) => { - props.addAudioToWord(props.entry.id, audioFile); + replaceAudio={(audio) => + props.repAudioInWord(props.entry.id, audio) + } + uploadAudio={(file) => { + props.addAudioToWord(props.entry.id, file); }} /> )} diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index 6c510683a6..c644c99e72 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -19,6 +19,7 @@ import { v4 } from "uuid"; import { AutocompleteSetting, Note, + Pronunciation, SemanticDomain, SemanticDomainTreeNode, Sense, @@ -29,12 +30,20 @@ import { getUserId } from "backend/localStorage"; import NewEntry from "components/DataEntry/DataEntryTable/NewEntry"; import RecentEntry from "components/DataEntry/DataEntryTable/RecentEntry"; import { filterWordsWithSenses } from "components/DataEntry/utilities"; -import { uploadFileFromUrl } from "components/Pronunciations/utilities"; +import { uploadFileFromPronunciation } from "components/Pronunciations/utilities"; import { StoreState } from "types"; import { Hash } from "types/hash"; import { useAppSelector } from "types/hooks"; import theme from "types/theme"; -import { newNote, newSense, newWord, simpleWord } from "types/word"; +import { + FileWithSpeakerId, + newNote, + newPronunciation, + newSense, + newWord, + simpleWord, + updateSpeakerInAudio, +} from "types/word"; import { defaultWritingSystem } from "types/writingSystem"; import SpellCheckerContext from "utilities/spellCheckerContext"; import { LevenshteinDistance } from "utilities/utilities"; @@ -173,7 +182,7 @@ export function updateEntryGloss( } interface NewEntryState { - newAudioUrls: string[]; + newAudio: Pronunciation[]; newGloss: string; newNote: string; newVern: string; @@ -183,7 +192,7 @@ interface NewEntryState { } const defaultNewEntryState = (): NewEntryState => ({ - newAudioUrls: [], + newAudio: [], newGloss: "", newNote: "", newVern: "", @@ -367,19 +376,29 @@ export default function DataEntryTable( }; /** Add an audio file to newAudioUrls. */ - const addNewAudioUrl = (file: File): void => { + const addNewAudio = (file: FileWithSpeakerId): void => { setState((prevState) => { - const newAudioUrls = [...prevState.newAudioUrls]; - newAudioUrls.push(URL.createObjectURL(file)); - return { ...prevState, newAudioUrls }; + const newAudio = [...prevState.newAudio]; + newAudio.push( + newPronunciation(URL.createObjectURL(file), file.speakerId) + ); + return { ...prevState, newAudio }; }); }; - /** Delete a url from newAudioUrls. */ - const delNewAudioUrl = (url: string): void => { + /** Delete a url from newAudio. */ + const delNewAudio = (url: string): void => { setState((prevState) => { - const newAudioUrls = prevState.newAudioUrls.filter((u) => u !== url); - return { ...prevState, newAudioUrls }; + const newAudio = prevState.newAudio.filter((a) => a.fileName !== url); + return { ...prevState, newAudio }; + }); + }; + + /** Replace the speaker of a newAudio. */ + const repNewAudio = (pro: Pronunciation): void => { + setState((prevState) => { + const newAudio = updateSpeakerInAudio(prevState.newAudio, pro); + return newAudio ? { ...prevState, newAudio } : prevState; }); }; @@ -554,14 +573,14 @@ export default function DataEntryTable( /** Given an array of audio file urls, add them all to specified word. */ const addAudiosToBackend = useCallback( - async (oldId: string, audioURLs: string[]): Promise => { - if (!audioURLs.length) { + async (oldId: string, audio: Pronunciation[]): Promise => { + if (!audio.length) { return oldId; } defunctWord(oldId); let newId = oldId; - for (const audioURL of audioURLs) { - newId = await uploadFileFromUrl(newId, audioURL); + for (const a of audio) { + newId = await uploadFileFromPronunciation(newId, a); } defunctWord(oldId, newId); return newId; @@ -571,9 +590,9 @@ export default function DataEntryTable( /** Given a single audio file, add to specified word. */ const addAudioFileToWord = useCallback( - async (oldId: string, audioFile: File): Promise => { + async (oldId: string, file: FileWithSpeakerId): Promise => { defunctWord(oldId); - const newId = await backend.uploadAudio(oldId, audioFile); + const newId = await backend.uploadAudio(oldId, file); defunctWord(oldId, newId); }, [defunctWord] @@ -584,7 +603,11 @@ export default function DataEntryTable( * Note: Only for use after backend.getDuplicateId(). */ const addDuplicateWord = useCallback( - async (word: Word, audioURLs: string[], oldId: string): Promise => { + async ( + word: Word, + audio: Pronunciation[], + oldId: string + ): Promise => { const isInDisplay = state.recentWords.findIndex((w) => w.word.id === oldId) > -1; @@ -592,7 +615,7 @@ export default function DataEntryTable( const newWord = await backend.updateDuplicate(oldId, word); defunctWord(oldId, newWord.id); - const newId = await addAudiosToBackend(newWord.id, audioURLs); + const newId = await addAudiosToBackend(newWord.id, audio); if (!isInDisplay) { addAllSensesToDisplay(await backend.getWord(newId)); @@ -611,6 +634,20 @@ export default function DataEntryTable( [defunctWord] ); + /** Updates speaker of specified audio in specified word. */ + const replaceAudioInWord = useCallback( + async (oldId: string, pro: Pronunciation): Promise => { + defunctWord(oldId); + const word = await backend.getWord(oldId); + const audio = updateSpeakerInAudio(word.audio, pro); + const newId = audio + ? (await backend.updateWord({ ...word, audio })).id + : oldId; + defunctWord(oldId, newId); + }, + [defunctWord] + ); + /** Updates word. */ const updateWordInBackend = useCallback( async (word: Word): Promise => { @@ -630,7 +667,7 @@ export default function DataEntryTable( const addNewWord = useCallback( async ( wordToAdd: Word, - audioURLs: string[], + audio: Pronunciation[], insertIndex?: number ): Promise => { wordToAdd.note.language = analysisLang.bcp47; @@ -638,11 +675,11 @@ export default function DataEntryTable( // Check if word is duplicate to existing word. const dupId = await backend.getDuplicateId(wordToAdd); if (dupId) { - return await addDuplicateWord(wordToAdd, audioURLs, dupId); + return await addDuplicateWord(wordToAdd, audio, dupId); } let word = await backend.createWord(wordToAdd); - const wordId = await addAudiosToBackend(word.id, audioURLs); + const wordId = await addAudiosToBackend(word.id, audio); if (wordId !== word.id) { word = await backend.getWord(wordId); } @@ -655,11 +692,11 @@ export default function DataEntryTable( const updateWordBackAndFront = async ( wordToUpdate: Word, senseGuid: string, - audioURLs?: string[] + audio?: Pronunciation[] ): Promise => { let word = await updateWordInBackend(wordToUpdate); - if (audioURLs?.length) { - const wordId = await addAudiosToBackend(word.id, audioURLs); + if (audio?.length) { + const wordId = await addAudiosToBackend(word.id, audio); word = await backend.getWord(wordId); } addToDisplay({ word, senseGuid }); @@ -695,7 +732,7 @@ export default function DataEntryTable( newSense(state.newGloss, lang, makeSemDomCurrent(props.semanticDomain)) ); word.note = newNote(state.newNote, lang); - await addNewWord(word, state.newAudioUrls); + await addNewWord(word, state.newAudio); }; /** Checks if sense already exists with this gloss and semantic domain. */ @@ -717,15 +754,15 @@ export default function DataEntryTable( val2: state.newGloss, }) ); - if (state.newAudioUrls.length) { - await addAudiosToBackend(wordId, state.newAudioUrls); + if (state.newAudio.length) { + await addAudiosToBackend(wordId, state.newAudio); } return; } else { await updateWordBackAndFront( addSemanticDomainToSense(semDom, oldWord, sense.guid), sense.guid, - state.newAudioUrls + state.newAudio ); return; } @@ -738,7 +775,7 @@ export default function DataEntryTable( const senses = [...oldWord.senses, sense]; const newWord: Word = { ...oldWord, senses }; - await updateWordBackAndFront(newWord, sense.guid, state.newAudioUrls); + await updateWordBackAndFront(newWord, sense.guid, state.newAudio); return; }; @@ -897,7 +934,8 @@ export default function DataEntryTable( updateVern={updateRecentVern} removeEntry={undoRecentEntry} addAudioToWord={addAudioFileToWord} - deleteAudioFromWord={deleteAudioFromWord} + delAudioFromWord={deleteAudioFromWord} + repAudioInWord={replaceAudioInWord} focusNewEntry={handleFocusNewEntry} analysisLang={analysisLang} vernacularLang={vernacularLang} @@ -916,9 +954,10 @@ export default function DataEntryTable( addNewEntry={addNewEntry} resetNewEntry={resetNewEntry} updateWordWithNewGloss={updateWordWithNewEntry} - newAudioUrls={state.newAudioUrls} - addNewAudioUrl={addNewAudioUrl} - delNewAudioUrl={delNewAudioUrl} + newAudio={state.newAudio} + addNewAudio={addNewAudio} + delNewAudio={delNewAudio} + repNewAudio={repNewAudio} newGloss={state.newGloss} setNewGloss={setNewGloss} newNote={state.newNote} diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index 72220ee85f..1101939a44 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -11,6 +11,7 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; import { Word } from "api/models"; +import { defaultState } from "components/App/DefaultState"; import { EntryNote, GlossWithSuggestions, @@ -20,21 +21,13 @@ import RecentEntry from "components/DataEntry/DataEntryTable/RecentEntry"; import { EditTextDialog } from "components/Dialogs"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; -import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import theme from "types/theme"; -import { simpleWord } from "types/word"; +import { newPronunciation, simpleWord } from "types/word"; import { newWritingSystem } from "types/writingSystem"; jest.mock("@mui/material/Autocomplete", () => "div"); -jest.mock("backend"); -jest.mock("components/Pronunciations/Recorder"); - -jest - .spyOn(window.HTMLMediaElement.prototype, "pause") - .mockImplementation(() => {}); - -const mockStore = configureMockStore()({ pronunciationsState }); +const mockStore = configureMockStore()(defaultState); const mockVern = "Vernacular"; const mockGloss = "Gloss"; const mockWord = simpleWord(mockVern, mockGloss); @@ -61,7 +54,8 @@ async function renderWithWord(word: Word): Promise { updateVern={mockUpdateVern} removeEntry={jest.fn()} addAudioToWord={jest.fn()} - deleteAudioFromWord={jest.fn()} + delAudioFromWord={jest.fn()} + repAudioInWord={jest.fn()} focusNewEntry={jest.fn()} analysisLang={newWritingSystem()} vernacularLang={newWritingSystem()} @@ -87,7 +81,8 @@ describe("ExistingEntry", () => { }); it("renders recorder and 3 players", async () => { - await renderWithWord({ ...mockWord, audio: ["a.wav", "b.wav", "c.wav"] }); + const audio = ["a.wav", "b.wav", "c.wav"].map((f) => newPronunciation(f)); + await renderWithWord({ ...mockWord, audio }); expect(testHandle.findAllByType(AudioPlayer).length).toEqual(3); expect(testHandle.findAllByType(AudioRecorder).length).toEqual(1); }); diff --git a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx index 4c0a883384..08422b9c2c 100644 --- a/src/components/DataEntry/DataEntryTable/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/index.test.tsx @@ -56,7 +56,6 @@ jest.mock( () => MockRecentEntry ); jest.mock("components/Pronunciations/PronunciationsFrontend", () => "div"); -jest.mock("components/Pronunciations/Recorder"); jest.mock("utilities/utilities"); jest.spyOn(window, "alert").mockImplementation(() => {}); diff --git a/src/components/Dialogs/RecordAudioDialog.tsx b/src/components/Dialogs/RecordAudioDialog.tsx new file mode 100644 index 0000000000..218ab759ce --- /dev/null +++ b/src/components/Dialogs/RecordAudioDialog.tsx @@ -0,0 +1,37 @@ +import { Dialog, DialogContent, DialogTitle, Icon } from "@mui/material"; +import { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { CloseButton } from "components/Buttons"; +import AudioRecorder from "components/Pronunciations/AudioRecorder"; + +interface RecordAudioDialogProps { + audioId: string; + close: () => void; + open: boolean; + titleId: string; + uploadAudio: (file: File) => Promise; +} + +export default function RecordAudioDialog( + props: RecordAudioDialogProps +): ReactElement { + const { t } = useTranslation(); + + return ( + + + {t(props.titleId)} + + + + + props.uploadAudio(file)} + /> + + + ); +} diff --git a/src/components/Dialogs/SubmitTextDialog.tsx b/src/components/Dialogs/SubmitTextDialog.tsx new file mode 100644 index 0000000000..d5f959a361 --- /dev/null +++ b/src/components/Dialogs/SubmitTextDialog.tsx @@ -0,0 +1,110 @@ +import { Clear } from "@mui/icons-material"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + InputAdornment, + TextField, +} from "@mui/material"; +import React, { ReactElement, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Key } from "ts-key-enum"; + +interface EditTextDialogProps { + open: boolean; + titleId: string; + close: () => void; + submitText: (newText: string) => void | Promise; + buttonIdCancel?: string; + buttonIdConfirm?: string; + buttonTextIdCancel?: string; + buttonTextIdConfirm?: string; + textFieldId?: string; +} + +/** Dialog for submitting new text */ +export default function SubmitTextDialog( + props: EditTextDialogProps +): ReactElement { + const [text, setText] = useState(""); + const { t } = useTranslation(); + + async function onConfirm(): Promise { + props.close(); + if (text) { + await props.submitText(text); + setText(""); + } + } + + function onCancel(): void { + setText(""); + props.close(); + } + + function escapeClose( + _: any, + reason: "backdropClick" | "escapeKeyDown" + ): void { + if (reason === "escapeKeyDown") { + props.close(); + } + } + + function confirmIfEnter(event: React.KeyboardEvent): void { + if (event.key === Key.Enter) { + onConfirm(); + } + } + + const endAdornment = ( + + setText("")} size="large"> + + + + ); + + return ( + + {t(props.titleId)} + + setText(event.target.value)} + onKeyPress={confirmIfEnter} + InputProps={{ endAdornment }} + id={props.textFieldId} + /> + + + + + + + ); +} diff --git a/src/components/Dialogs/UploadImageDialog.tsx b/src/components/Dialogs/UploadImageDialog.tsx index 226402b45d..ee928170cd 100644 --- a/src/components/Dialogs/UploadImageDialog.tsx +++ b/src/components/Dialogs/UploadImageDialog.tsx @@ -1,7 +1,8 @@ -import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { Dialog, DialogContent, DialogTitle, Icon } from "@mui/material"; import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; +import { CloseButton } from "components/Buttons"; import UploadImage from "components/Dialogs/UploadImage"; interface UploadImageDialogProps { @@ -18,7 +19,11 @@ export default function UploadImageDialog( return ( - {t(props.titleId)} + + {t(props.titleId)} + + + void; + deleteButtonId?: string; + deleteImage?: () => void | Promise; + deleteTextId?: string; + imgSrc: string; + open: boolean; + title: string; +} + +export default function ViewImageDialog( + props: ViewImageDialogProps +): ReactElement { + const handleDelete = async (): Promise => { + if (props.deleteImage) { + await props.deleteImage(); + } + props.close(); + }; + + return ( + + + {props.title} + + + + + + + + + + + + + ); +} diff --git a/src/components/Dialogs/index.ts b/src/components/Dialogs/index.ts index 7913acbec1..fa2d83daf3 100644 --- a/src/components/Dialogs/index.ts +++ b/src/components/Dialogs/index.ts @@ -2,12 +2,18 @@ import ButtonConfirmation from "components/Dialogs/ButtonConfirmation"; import CancelConfirmDialog from "components/Dialogs/CancelConfirmDialog"; import DeleteEditTextDialog from "components/Dialogs/DeleteEditTextDialog"; import EditTextDialog from "components/Dialogs/EditTextDialog"; +import RecordAudioDialog from "components/Dialogs/RecordAudioDialog"; +import SubmitTextDialog from "components/Dialogs/SubmitTextDialog"; import UploadImageDialog from "components/Dialogs/UploadImageDialog"; +import ViewImageDialog from "components/Dialogs/ViewImageDialog"; export { ButtonConfirmation, CancelConfirmDialog, DeleteEditTextDialog, EditTextDialog, + RecordAudioDialog, + SubmitTextDialog, UploadImageDialog, + ViewImageDialog, }; diff --git a/src/components/Project/ProjectActions.ts b/src/components/Project/ProjectActions.ts index ba4348c53c..17d2e2e939 100644 --- a/src/components/Project/ProjectActions.ts +++ b/src/components/Project/ProjectActions.ts @@ -1,11 +1,12 @@ import { Action, PayloadAction } from "@reduxjs/toolkit"; -import { Project, User } from "api/models"; +import { Project, Speaker, User } from "api/models"; import { getAllProjectUsers, updateProject } from "backend"; import { setProjectId } from "backend/localStorage"; import { resetAction, setProjectAction, + setSpeakerAction, setUsersAction, } from "components/Project/ProjectReducer"; import { StoreStateDispatch } from "types/Redux/actions"; @@ -21,7 +22,11 @@ export function setCurrentProject(project?: Project): PayloadAction { return setProjectAction(project ?? newProject()); } -export function setCurrentProjectUsers(users?: User[]): PayloadAction { +export function setCurrentSpeaker(speaker?: Speaker): PayloadAction { + return setSpeakerAction(speaker); +} + +export function setCurrentUsers(users?: User[]): PayloadAction { return setUsersAction(users ?? []); } @@ -29,7 +34,7 @@ export function setCurrentProjectUsers(users?: User[]): PayloadAction { export function asyncRefreshProjectUsers(projectId: string) { return async (dispatch: StoreStateDispatch) => { - dispatch(setCurrentProjectUsers(await getAllProjectUsers(projectId))); + dispatch(setCurrentUsers(await getAllProjectUsers(projectId))); }; } diff --git a/src/components/Project/ProjectReducer.ts b/src/components/Project/ProjectReducer.ts index 7670d31c74..ba5977db51 100644 --- a/src/components/Project/ProjectReducer.ts +++ b/src/components/Project/ProjectReducer.ts @@ -10,10 +10,14 @@ const projectSlice = createSlice({ resetAction: () => defaultState, setProjectAction: (state, action) => { if (state.project.id !== action.payload.id) { + state.speaker = undefined; state.users = []; } state.project = action.payload; }, + setSpeakerAction: (state, action) => { + state.speaker = action.payload; + }, setUsersAction: (state, action) => { state.users = action.payload; }, @@ -22,7 +26,11 @@ const projectSlice = createSlice({ builder.addCase(StoreActionTypes.RESET, () => defaultState), }); -export const { resetAction, setProjectAction, setUsersAction } = - projectSlice.actions; +export const { + resetAction, + setProjectAction, + setSpeakerAction, + setUsersAction, +} = projectSlice.actions; export default projectSlice.reducer; diff --git a/src/components/Project/ProjectReduxTypes.ts b/src/components/Project/ProjectReduxTypes.ts index 1ba4450825..51a4579cff 100644 --- a/src/components/Project/ProjectReduxTypes.ts +++ b/src/components/Project/ProjectReduxTypes.ts @@ -1,8 +1,9 @@ -import { Project, User } from "api/models"; +import { Project, Speaker, User } from "api/models"; import { newProject } from "types/project"; export interface CurrentProjectState { project: Project; + speaker?: Speaker; users: User[]; } diff --git a/src/components/Project/tests/ProjectActions.test.tsx b/src/components/Project/tests/ProjectActions.test.tsx index 3c90e586d2..3fc4fc0246 100644 --- a/src/components/Project/tests/ProjectActions.test.tsx +++ b/src/components/Project/tests/ProjectActions.test.tsx @@ -1,6 +1,6 @@ import { PreloadedState } from "redux"; -import { Project } from "api/models"; +import { Project, Speaker } from "api/models"; import { defaultState } from "components/App/DefaultState"; import { asyncRefreshProjectUsers, @@ -33,13 +33,18 @@ describe("ProjectActions", () => { const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore({ ...persistedDefaultState, - currentProjectState: { project: proj, users: [newUser()] }, + currentProjectState: { + project: proj, + speaker: {} as Speaker, + users: [newUser()], + }, }); const id = "new-id"; await store.dispatch(asyncUpdateCurrentProject({ ...proj, id })); expect(mockUpdateProject).toHaveBeenCalledTimes(1); - const { project, users } = store.getState().currentProjectState; + const { project, speaker, users } = store.getState().currentProjectState; expect(project.id).toEqual(id); + expect(speaker).toBeUndefined(); expect(users).toHaveLength(0); }); @@ -47,13 +52,18 @@ describe("ProjectActions", () => { const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore({ ...persistedDefaultState, - currentProjectState: { project: proj, users: [newUser()] }, + currentProjectState: { + project: proj, + speaker: {} as Speaker, + users: [newUser()], + }, }); const name = "new-name"; await store.dispatch(asyncUpdateCurrentProject({ ...proj, name })); expect(mockUpdateProject).toHaveBeenCalledTimes(1); - const { project, users } = store.getState().currentProjectState; + const { project, speaker, users } = store.getState().currentProjectState; expect(project.name).toEqual(name); + expect(speaker).not.toBeUndefined(); expect(users).toHaveLength(1); }); }); @@ -63,13 +73,18 @@ describe("ProjectActions", () => { const proj: Project = { ...newProject(), id: mockProjId }; const store = setupStore({ ...persistedDefaultState, - currentProjectState: { project: proj, users: [] }, + currentProjectState: { + project: proj, + speaker: {} as Speaker, + users: [], + }, }); const mockUsers = [newUser(), newUser(), newUser()]; mockGetAllProjectUsers.mockResolvedValueOnce(mockUsers); await store.dispatch(asyncRefreshProjectUsers("mockProjId")); - const { project, users } = store.getState().currentProjectState; + const { project, speaker, users } = store.getState().currentProjectState; expect(project.id).toEqual(mockProjId); + expect(speaker).not.toBeUndefined(); expect(users).toHaveLength(mockUsers.length); }); }); @@ -78,6 +93,7 @@ describe("ProjectActions", () => { it("correctly affects state", () => { const nonDefaultState = { project: { ...newProject(), id: "nonempty-string" }, + speaker: {} as Speaker, users: [newUser()], }; const store = setupStore({ @@ -85,8 +101,9 @@ describe("ProjectActions", () => { currentProjectState: nonDefaultState, }); store.dispatch(clearCurrentProject()); - const { project, users } = store.getState().currentProjectState; + const { project, speaker, users } = store.getState().currentProjectState; expect(project.id).toEqual(""); + expect(speaker).toBeUndefined(); expect(users).toHaveLength(0); }); }); diff --git a/src/components/ProjectSettings/index.tsx b/src/components/ProjectSettings/index.tsx index a0fab81c71..05090a3797 100644 --- a/src/components/ProjectSettings/index.tsx +++ b/src/components/ProjectSettings/index.tsx @@ -8,6 +8,7 @@ import { Language, People, PersonAdd, + RecordVoiceOver, Settings, Sms, } from "@mui/icons-material"; @@ -50,6 +51,7 @@ import ProjectSchedule from "components/ProjectSettings/ProjectSchedule"; import ProjectSelect from "components/ProjectSettings/ProjectSelect"; import ActiveProjectUsers from "components/ProjectUsers/ActiveProjectUsers"; import AddProjectUsers from "components/ProjectUsers/AddProjectUsers"; +import ProjectSpeakersList from "components/ProjectUsers/ProjectSpeakersList"; import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; import { Path } from "types/path"; @@ -70,6 +72,7 @@ export enum Setting { Languages = "SettingLanguages", Name = "SettingName", Schedule = "SettingSchedule", + Speakers = "SettingSpeakers", UserAdd = "SettingUserAdd", Users = "SettingUsers", } @@ -229,6 +232,15 @@ export default function ProjectSettingsComponent(): ReactElement { body={} /> )} + + {/* Manage project speakers */} + {permissions.includes(Permission.DeleteEditSettingsAndUsers) && ( + } + title={t("projectSettings.speaker.label")} + body={} + /> + )} diff --git a/src/components/ProjectSettings/tests/SettingsTabTypes.ts b/src/components/ProjectSettings/tests/SettingsTabTypes.ts index a8ca20d590..b9acc22b6d 100644 --- a/src/components/ProjectSettings/tests/SettingsTabTypes.ts +++ b/src/components/ProjectSettings/tests/SettingsTabTypes.ts @@ -12,7 +12,11 @@ const settingsByTab: Record = { [ProjectSettingsTab.ImportExport]: [Setting.Export, Setting.Import], [ProjectSettingsTab.Languages]: [Setting.Languages], [ProjectSettingsTab.Schedule]: [Setting.Schedule], - [ProjectSettingsTab.Users]: [Setting.UserAdd, Setting.Users], + [ProjectSettingsTab.Users]: [ + Setting.Speakers, + Setting.UserAdd, + Setting.Users, + ], }; /** A dictionary indexed by all the project permissions. For each key permission, @@ -24,6 +28,7 @@ const settingsByPermission: Record = { Setting.Autocomplete, Setting.Languages, Setting.Name, + Setting.Speakers, Setting.UserAdd, Setting.Users, ], diff --git a/src/components/ProjectSettings/tests/index.test.tsx b/src/components/ProjectSettings/tests/index.test.tsx index b3cf75cbc9..9edc6e9629 100644 --- a/src/components/ProjectSettings/tests/index.test.tsx +++ b/src/components/ProjectSettings/tests/index.test.tsx @@ -26,6 +26,7 @@ jest.mock("react-router-dom", () => ({ jest.mock("backend", () => ({ canUploadLift: () => Promise.resolve(false), + getAllSpeakers: () => Promise.resolve([]), getAllUsers: () => Promise.resolve([]), getCurrentPermissions: () => mockGetCurrentPermissions(), getUserRoles: () => Promise.resolve([]), diff --git a/src/components/ProjectUsers/ProjectSpeakersList.tsx b/src/components/ProjectUsers/ProjectSpeakersList.tsx new file mode 100644 index 0000000000..6b522729c7 --- /dev/null +++ b/src/components/ProjectUsers/ProjectSpeakersList.tsx @@ -0,0 +1,158 @@ +import { Add, Edit } from "@mui/icons-material"; +import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; +import { ReactElement, useCallback, useEffect, useState } from "react"; + +import { ConsentType, Speaker } from "api/models"; +import { + createSpeaker, + deleteSpeaker, + getAllSpeakers, + updateSpeakerName, +} from "backend"; +import { + DeleteButtonWithDialog, + IconButtonWithTooltip, +} from "components/Buttons"; +import { EditTextDialog, SubmitTextDialog } from "components/Dialogs"; +import SpeakerConsentListItemIcon from "components/ProjectUsers/SpeakerConsentListItemIcon"; + +export default function ProjectSpeakersList(props: { + projectId: string; +}): ReactElement { + const [projSpeakers, setProjSpeakers] = useState([]); + + const getProjectSpeakers = useCallback(() => { + if (props.projectId) { + getAllSpeakers(props.projectId).then(setProjSpeakers); + } + }, [props.projectId]); + + useEffect(() => { + getProjectSpeakers(); + }, [getProjectSpeakers]); + + return ( + + {projSpeakers.map((s) => ( + + ))} + + + ); +} + +interface ProjSpeakerProps { + projectId: string; + refresh: () => void | Promise; + speaker: Speaker; +} + +export function SpeakerListItem(props: ProjSpeakerProps): ReactElement { + const { refresh, speaker } = props; + return ( + + + + + + + ); +} + +function EditSpeakerNameListItemIcon(props: ProjSpeakerProps): ReactElement { + const [open, setOpen] = useState(false); + + const handleUpdateText = async (name: string): Promise => { + await updateSpeakerName(props.speaker.id, name, props.projectId); + await props.refresh(); + }; + + return ( + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.edit" + /> + setOpen(false)} + open={open} + text={props.speaker.name} + textFieldId="project-speakers-edit-name" + titleId="projectSettings.speaker.edit" + updateText={handleUpdateText} + /> + + ); +} + +function DeleteSpeakerListItemIcon(props: ProjSpeakerProps): ReactElement { + const handleDelete = async (): Promise => { + await deleteSpeaker(props.speaker.id, props.projectId); + await props.refresh(); + }; + + return ( + + + + ); +} + +interface AddSpeakerProps { + projectId: string; + refresh: () => void | Promise; +} + +export function AddSpeakerListItem(props: AddSpeakerProps): ReactElement { + const [open, setOpen] = useState(false); + + const handleSubmitText = async (name: string): Promise => { + await createSpeaker(name, props.projectId); + await props.refresh(); + }; + + return ( + + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.add" + /> + + setOpen(false)} + open={open} + submitText={handleSubmitText} + textFieldId="project-speakers-add-name" + titleId="projectSettings.speaker.enterName" + /> + + ); +} diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx new file mode 100644 index 0000000000..bfeec18519 --- /dev/null +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -0,0 +1,187 @@ +import { Add, AddPhotoAlternate, Image, Mic } from "@mui/icons-material"; +import { ListItemIcon, ListItemText, Menu, MenuItem } from "@mui/material"; +import { ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConsentType, Speaker } from "api/models"; +import { + getConsentImageSrc, + getConsentUrl, + removeConsent, + uploadConsent, +} from "backend"; +import { IconButtonWithTooltip } from "components/Buttons"; +import { + RecordAudioDialog, + UploadImageDialog, + ViewImageDialog, +} from "components/Dialogs"; +import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import { newPronunciation } from "types/word"; + +export const enum ListItemIconId { + AddConsent, + PlayAudio, + RecordAudio, + ShowImage, + UploadAudio, +} + +interface ConsentIconProps { + refresh: () => void | Promise; + speaker: Speaker; +} + +export default function SpeakerConsentListItemIcon( + props: ConsentIconProps +): ReactElement { + const [anchor, setAnchor] = useState(); + + const unsetAnchorAndRefresh = async (): Promise => { + setAnchor(undefined); + await props.refresh(); + }; + + return props.speaker.consent === ConsentType.Audio ? ( + + ) : props.speaker.consent === ConsentType.Image ? ( + + ) : ( + + } + onClick={(event) => setAnchor(event.currentTarget)} + textId="projectSettings.speaker.consent.add" + /> + setAnchor(undefined)} + open={Boolean(anchor)} + > + + + + + ); +} + +function PlayConsentListItemIcon(props: ConsentIconProps): ReactElement { + const handleDeleteAudio = async (): Promise => { + await removeConsent(props.speaker); + await props.refresh(); + }; + + return ( + + + + ); +} + +function ShowConsentListItemIcon(props: ConsentIconProps): ReactElement { + const [imgSrc, setImgSrc] = useState(""); + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + useEffect(() => { + getConsentImageSrc(props.speaker).then(setImgSrc); + }, [props.speaker]); + + const handleDeleteImage = async (): Promise => { + await removeConsent(props.speaker); + setOpen(false); + await props.refresh(); + }; + + return ( + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.consent.view" + /> + setOpen(false)} + imgSrc={imgSrc} + open={open} + title={t("projectSettings.speaker.consent.viewTitle", { + val: props.speaker.name, + })} + deleteImage={handleDeleteImage} + deleteTextId="projectSettings.speaker.consent.remove" + /> + + ); +} + +function RecordConsentMenuItem(props: ConsentIconProps): ReactElement { + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + const handleUploadAudio = async (audioFile: File): Promise => { + await uploadConsent(props.speaker, audioFile); + setOpen(false); + await props.refresh(); + }; + + return ( + <> + setOpen(true)}> + + + + + {t("projectSettings.speaker.consent.record")} + + + setOpen(false)} + open={open} + titleId="projectSettings.speaker.consent.record" + uploadAudio={handleUploadAudio} + /> + + ); +} + +function UploadConsentMenuItem(props: ConsentIconProps): ReactElement { + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + const handleUploadImage = async (imageFile: File): Promise => { + await uploadConsent(props.speaker, imageFile); + await props.refresh(); + }; + + return ( + <> + setOpen(true)}> + + + + + {t("projectSettings.speaker.consent.upload")} + + + setOpen(false)} + open={open} + titleId="projectSettings.speaker.consent.upload" + uploadImage={handleUploadImage} + /> + + ); +} diff --git a/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx b/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx new file mode 100644 index 0000000000..9a42c9368b --- /dev/null +++ b/src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx @@ -0,0 +1,49 @@ +import renderer from "react-test-renderer"; + +import "tests/reactI18nextMock.ts"; + +import ProjectSpeakersList, { + AddSpeakerListItem, + SpeakerListItem, +} from "components/ProjectUsers/ProjectSpeakersList"; +import { randomSpeaker } from "types/project"; + +jest.mock("backend", () => ({ + createSpeaker: (args: any[]) => mockCreateSpeaker(...args), + deleteSpeaker: (args: any[]) => mockDeleteSpeaker(...args), + getAllSpeakers: (projectId?: string) => mockGetAllSpeakers(projectId), + updateSpeakerName: (args: any[]) => mockUpdateSpeakerName(...args), +})); + +const mockCreateSpeaker = jest.fn(); +const mockDeleteSpeaker = jest.fn(); +const mockGetAllSpeakers = jest.fn(); +const mockUpdateSpeakerName = jest.fn(); + +const mockProjId = "mock-project-id"; +const mockSpeakers = [randomSpeaker(), randomSpeaker(), randomSpeaker()]; + +let testRenderer: renderer.ReactTestRenderer; + +const renderProjectSpeakersList = async ( + projId = mockProjId +): Promise => { + await renderer.act(async () => { + testRenderer = renderer.create(); + }); +}; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe("ProjectSpeakersList", () => { + it("shows right number of speakers and an item to add a speaker", async () => { + mockGetAllSpeakers.mockResolvedValue(mockSpeakers); + await renderProjectSpeakersList(); + expect(testRenderer.root.findAllByType(SpeakerListItem)).toHaveLength( + mockSpeakers.length + ); + expect(testRenderer.root.findByType(AddSpeakerListItem)).toBeTruthy; + }); +}); diff --git a/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx b/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx new file mode 100644 index 0000000000..7295f5c166 --- /dev/null +++ b/src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx @@ -0,0 +1,130 @@ +import { PlayArrow } from "@mui/icons-material"; +import "@testing-library/jest-dom"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReactElement } from "react"; + +import "tests/reactI18nextMock"; + +import { ConsentType, Speaker } from "api/models"; +import SpeakerConsentListItemIcon, { + ListItemIconId, +} from "components/ProjectUsers/SpeakerConsentListItemIcon"; +import { randomSpeaker } from "types/project"; + +jest.mock("backend", () => ({ + getConsentImageSrc: (speaker: Speaker) => mockGetConsentImageSrc(speaker), + getConsentUrl: (speaker: Speaker) => mockGetConsentUrl(speaker), + removeConsent: (speaker: Speaker) => mockRemoveConsent(speaker), + uploadConsent: (args: any[]) => mockUploadConsent(...args), +})); +jest.mock( + "components/Pronunciations/AudioPlayer", + () => () => mockAudioPlayer() +); + +const mockAudioPlayer = function MockAudioPlayer(): ReactElement { + return ; +}; + +const mockGetConsentImageSrc = jest.fn(); +const mockGetConsentUrl = jest.fn(); +const mockRemoveConsent = jest.fn(); +const mockUploadConsent = jest.fn(); + +function setMockFunctions(): void { + mockGetConsentImageSrc.mockResolvedValue(""); + mockGetConsentUrl.mockReturnValue(""); + mockRemoveConsent.mockResolvedValue(""); + mockUploadConsent.mockResolvedValue(randomSpeaker()); +} + +const mockRefresh = jest.fn(); +const mockSpeaker = randomSpeaker(); + +async function renderSpeakerConsentListItemIcon( + speaker = mockSpeaker +): Promise { + await act(async () => { + render( + + ); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + setMockFunctions(); +}); + +afterEach(cleanup); + +describe("SpeakerConsentListItemIcon", () => { + describe("ConsentType.None", () => { + it("has Add button icon", async () => { + await renderSpeakerConsentListItemIcon({ + ...mockSpeaker, + consent: ConsentType.None, + }); + expect(screen.queryByTestId(ListItemIconId.AddConsent)).not.toBeNull; + expect(screen.queryByTestId(ListItemIconId.PlayAudio)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.ShowImage)).toBeNull; + }); + + it("opens menu when clicked", async () => { + const agent = userEvent.setup(); + await renderSpeakerConsentListItemIcon({ + ...mockSpeaker, + consent: ConsentType.None, + }); + expect(screen.queryByRole("menu")).toBeNull; + expect(screen.queryByTestId(ListItemIconId.RecordAudio)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.UploadAudio)).toBeNull; + + await act(async () => { + await agent.click(screen.getByRole("button")); + }); + expect(screen.queryByRole("menu")).not.toBeNull; + expect(screen.queryByTestId(ListItemIconId.RecordAudio)).not.toBeNull; + expect(screen.queryByTestId(ListItemIconId.UploadAudio)).not.toBeNull; + }); + }); + + describe("ConsentType.Audio", () => { + it("has AudioPlayer (mocked out by PlayArrow icon)", async () => { + await renderSpeakerConsentListItemIcon({ + ...mockSpeaker, + consent: ConsentType.Audio, + }); + expect(screen.queryByTestId(ListItemIconId.AddConsent)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.PlayAudio)).not.toBeNull; + expect(screen.queryByTestId(ListItemIconId.ShowImage)).toBeNull; + }); + }); + + describe("ConsentType.Image", () => { + it("has Image button icon", async () => { + await renderSpeakerConsentListItemIcon({ + ...mockSpeaker, + consent: ConsentType.Image, + }); + expect(screen.queryByTestId(ListItemIconId.AddConsent)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.PlayAudio)).toBeNull; + expect(screen.queryByTestId(ListItemIconId.ShowImage)).not.toBeNull; + }); + + it("opens dialog when clicked", async () => { + const agent = userEvent.setup(); + await renderSpeakerConsentListItemIcon({ + ...mockSpeaker, + consent: ConsentType.Image, + }); + expect(screen.queryAllByRole("dialog")).toBeNull; + + await act(async () => { + await agent.click(screen.getByRole("button")); + }); + expect(screen.queryAllByRole("dialog")).not.toBeNull; + }); + }); +}); diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 7e52a549af..e917890e80 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -1,5 +1,14 @@ -import { Delete, PlayArrow, Stop } from "@mui/icons-material"; -import { Fade, IconButton, Menu, MenuItem, Tooltip } from "@mui/material"; +import { Delete, PlayArrow, RecordVoiceOver, Stop } from "@mui/icons-material"; +import { + Dialog, + DialogContent, + DialogTitle, + Fade, + IconButton, + Menu, + MenuItem, + Tooltip, +} from "@mui/material"; import { CSSProperties, ReactElement, @@ -9,6 +18,9 @@ import { } from "react"; import { useTranslation } from "react-i18next"; +import { Pronunciation, Speaker } from "api/models"; +import { getSpeaker } from "backend"; +import { SpeakerMenuList } from "components/AppBar/SpeakerMenu"; import { ButtonConfirmation } from "components/Dialogs"; import { playing, @@ -20,11 +32,12 @@ import { useAppDispatch, useAppSelector } from "types/hooks"; import { themeColors } from "types/theme"; interface PlayerProps { - deleteAudio: (fileName: string) => void; - fileName: string; + audio: Pronunciation; + deleteAudio?: (fileName: string) => void; onClick?: () => void; - pronunciationUrl: string; + pronunciationUrl?: string; size?: "large" | "medium" | "small"; + updateAudioSpeaker?: (speakerId?: string) => Promise | void; warningTextId?: string; } @@ -33,13 +46,17 @@ const iconStyle: CSSProperties = { color: themeColors.success }; export default function AudioPlayer(props: PlayerProps): ReactElement { const isPlaying = useAppSelector( (state: StoreState) => - state.pronunciationsState.fileName === props.fileName && + state.pronunciationsState.fileName === props.audio.fileName && state.pronunciationsState.status === PronunciationsStatus.Playing ); - const [audio] = useState(new Audio(props.pronunciationUrl)); + const [audio] = useState( + new Audio(props.pronunciationUrl ?? props.audio.fileName) + ); const [anchor, setAnchor] = useState(); const [deleteConf, setDeleteConf] = useState(false); + const [speaker, setSpeaker] = useState(); + const [speakerDialog, setSpeakerDialog] = useState(false); const dispatch = useAppDispatch(); const dispatchReset = useCallback( @@ -48,6 +65,17 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { ); const { t } = useTranslation(); + const canChangeSpeaker = props.updateAudioSpeaker && !props.audio.protected; + const canDeleteAudio = props.deleteAudio && !props.audio.protected; + + useEffect(() => { + if (props.audio.speakerId) { + getSpeaker(props.audio.speakerId).then(setSpeaker); + } else { + setSpeaker(undefined); + } + }, [props.audio.speakerId]); + useEffect(() => { if (isPlaying) { audio.addEventListener("ended", dispatchReset); @@ -60,7 +88,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { function togglePlay(): void { if (!isPlaying) { - dispatch(playing(props.fileName)); + dispatch(playing(props.audio.fileName)); } else { dispatchReset(); } @@ -70,43 +98,88 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { if (props.onClick) { props.onClick(); } - if (event?.shiftKey) { + if (event?.shiftKey && canDeleteAudio) { setDeleteConf(true); } else { togglePlay(); } } - function handleClose(): void { + function handleMenuOnClose(): void { setAnchor(undefined); enableContextMenu(); } - function disableContextMenu(event: any): void { + function preventEventOnce(event: any): void { event.preventDefault(); enableContextMenu(); } + + function disableContextMenu(): void { + document.addEventListener("contextmenu", preventEventOnce, false); + } + function enableContextMenu(): void { - document.removeEventListener("contextmenu", disableContextMenu, false); + document.removeEventListener("contextmenu", preventEventOnce, false); } function handleTouch(event: any): void { - // Temporarily disable context menu since some browsers - // interpret a long-press touch as a right-click. - document.addEventListener("contextmenu", disableContextMenu, false); - setAnchor(event.currentTarget); + if (canChangeSpeaker || canDeleteAudio) { + // Temporarily disable context menu since some browsers + // interpret a long-press touch as a right-click. + disableContextMenu(); + setAnchor(event.currentTarget); + } } + async function handleOnSelect(speaker?: Speaker): Promise { + if (canChangeSpeaker) { + await props.updateAudioSpeaker!(speaker?.id); + } + setSpeakerDialog(false); + } + + function handleOnAuxClick(): void { + if (canChangeSpeaker) { + // Temporarily disable context menu triggered by right-click. + disableContextMenu(); + setSpeakerDialog(true); + } + } + + const tooltipTexts = [t("pronunciations.playTooltip")]; + if (canDeleteAudio) { + tooltipTexts.push(t("pronunciations.deleteTooltip")); + } + if (props.audio.protected) { + tooltipTexts.push(t("pronunciations.protectedTooltip")); + } + if (speaker) { + tooltipTexts.push(t("pronunciations.speaker", { val: speaker.name })); + } + if (canChangeSpeaker) { + tooltipTexts.push( + speaker + ? t("pronunciations.speakerChange") + : t("pronunciations.speakerAdd") + ); + } + + const multilineTooltipText = (lines: string[]): ReactElement => ( +
{lines.join("\n")}
+ ); + return ( <> - + {isPlaying ? : } @@ -117,7 +190,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { id="play-menu" anchorEl={anchor} open={Boolean(anchor)} - onClose={handleClose} + onClose={handleMenuOnClose} anchorOrigin={{ vertical: "top", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} > @@ -125,30 +198,49 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { id={isPlaying ? "audio-stop" : "audio-play"} onClick={() => { togglePlay(); - handleClose(); + handleMenuOnClose(); }} > {isPlaying ? : } - { - setDeleteConf(true); - handleClose(); - }} - > - - + {canChangeSpeaker && ( + { + setSpeakerDialog(true); + handleMenuOnClose(); + }} + > + + + )} + {canDeleteAudio && ( + { + setDeleteConf(true); + handleMenuOnClose(); + }} + > + + + )} setDeleteConf(false)} - onConfirm={() => props.deleteAudio(props.fileName)} + onConfirm={() => props.deleteAudio!(props.audio.fileName)} buttonIdClose="audio-delete-cancel" buttonIdConfirm="audio-delete-confirm" /> + setSpeakerDialog(false)}> + {t("pronunciations.speakerSelect")} + + + + ); } diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index eff2e2edb6..ac307873b3 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -6,14 +6,21 @@ import Recorder from "components/Pronunciations/Recorder"; import RecorderContext from "components/Pronunciations/RecorderContext"; import RecorderIcon from "components/Pronunciations/RecorderIcon"; import { getFileNameForWord } from "components/Pronunciations/utilities"; +import { StoreState } from "types"; +import { useAppSelector } from "types/hooks"; +import { FileWithSpeakerId } from "types/word"; interface RecorderProps { - wordId: string; - uploadAudio: (audioFile: File) => void; + id: string; + noSpeaker?: boolean; onClick?: () => void; + uploadAudio: (file: FileWithSpeakerId) => void; } export default function AudioRecorder(props: RecorderProps): ReactElement { + const speakerId = useAppSelector( + (state: StoreState) => state.currentProjectState.speaker?.id + ); const recorder = useContext(RecorderContext); const { t } = useTranslation(); @@ -30,17 +37,20 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { toast.error(t("pronunciations.noMicAccess")); return; } - const fileName = getFileNameForWord(props.wordId); - const options: FilePropertyBag = { + const fileName = getFileNameForWord(props.id); + const file = new File([blob], fileName, { lastModified: Date.now(), type: Recorder.blobType, - }; - props.uploadAudio(new File([blob], fileName, options)); + }); + if (!props.noSpeaker) { + (file as FileWithSpeakerId).speakerId = speakerId; + } + props.uploadAudio(file); } return ( diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index 0e32782d78..b1d7a15fb0 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -1,16 +1,19 @@ import { memo, ReactElement } from "react"; +import { Pronunciation } from "api/models"; import { getAudioUrl } from "backend"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; +import { FileWithSpeakerId } from "types/word"; interface PronunciationsBackendProps { + audio: Pronunciation[]; playerOnly?: boolean; overrideMemo?: boolean; - pronunciationFiles: string[]; wordId: string; - deleteAudio: (fileName: string) => void; - uploadAudio?: (audioFile: File) => void; + deleteAudio?: (fileName: string) => void; + replaceAudio?: (audio: Pronunciation) => void; + uploadAudio?: (file: FileWithSpeakerId) => void; } /** Audio recording/playing component for backend audio. */ @@ -24,21 +27,23 @@ export function PronunciationsBackend( console.warn("uploadAudio undefined; playerOnly should be set to true"); } - const audioButtons: ReactElement[] = props.pronunciationFiles.map( - (fileName) => ( - - ) - ); + const audioButtons: ReactElement[] = props.audio.map((a) => ( + props.replaceAudio!({ ...a, speakerId: id ?? "" })) + } + /> + )); return ( <> {!props.playerOnly && !!props.uploadAudio && ( - + )} {audioButtons} @@ -56,8 +61,7 @@ function propsAreEqual( } return ( prev.wordId === next.wordId && - JSON.stringify(prev.pronunciationFiles) === - JSON.stringify(next.pronunciationFiles) + JSON.stringify(prev.audio) === JSON.stringify(next.audio) ); } diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index 1e2cd8542f..d66d7718f3 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -1,36 +1,39 @@ import { ReactElement } from "react"; +import { Pronunciation } from "api/models"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; +import { FileWithSpeakerId } from "types/word"; -interface PronunciationFrontendProps { - pronunciationFiles: string[]; +interface PronunciationsFrontendProps { + audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; - uploadAudio: (audioFile: File) => void; + replaceAudio: (audio: Pronunciation) => void; + uploadAudio: (file: FileWithSpeakerId) => void; onClick?: () => void; } /** Audio recording/playing component for audio being recorded and held in the frontend. */ export default function PronunciationsFrontend( - props: PronunciationFrontendProps + props: PronunciationsFrontendProps ): ReactElement { - const audioButtons: ReactElement[] = props.pronunciationFiles.map( - (fileName) => ( - - ) - ); + const audioButtons: ReactElement[] = props.audio.map((a) => ( + + props.replaceAudio({ ...a, speakerId: id ?? "" }) + } + /> + )); return ( <> diff --git a/src/components/Pronunciations/RecorderIcon.tsx b/src/components/Pronunciations/RecorderIcon.tsx index d920d35c8b..263c6dab7f 100644 --- a/src/components/Pronunciations/RecorderIcon.tsx +++ b/src/components/Pronunciations/RecorderIcon.tsx @@ -16,7 +16,7 @@ export const recordButtonId = "recordingButton"; export const recordIconId = "recordingIcon"; interface RecorderIconProps { - wordId: string; + id: string; startRecording: () => void; stopRecording: () => void; } @@ -25,14 +25,14 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { const isRecording = useAppSelector( (state: StoreState) => state.pronunciationsState.status === PronunciationsStatus.Recording && - state.pronunciationsState.wordId === props.wordId + state.pronunciationsState.wordId === props.id ); const dispatch = useAppDispatch(); const { t } = useTranslation(); function toggleIsRecordingToTrue(): void { - dispatch(recording(props.wordId)); + dispatch(recording(props.id)); props.startRecording(); } function toggleIsRecordingToFalse(): void { diff --git a/src/components/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index 2f1cc3e2e9..546bca627f 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -5,26 +5,22 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; +import { defaultState } from "components/App/DefaultState"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import RecorderIcon, { recordButtonId, recordIconId, } from "components/Pronunciations/RecorderIcon"; -import { - defaultState as pronunciationsState, - PronunciationsStatus, -} from "components/Pronunciations/Redux/PronunciationsReduxTypes"; +import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import { StoreState } from "types"; import theme, { themeColors } from "types/theme"; -jest.mock("components/Pronunciations/Recorder"); - let testRenderer: ReactTestRenderer; -const createMockStore = configureMockStore(); -const mockStore = createMockStore({ pronunciationsState }); +const mockStore = configureMockStore()(defaultState); function mockRecordingState(wordId: string): Partial { return { + ...defaultState, pronunciationsState: { fileName: "", status: PronunciationsStatus.Recording, @@ -39,7 +35,7 @@ beforeAll(() => { - + @@ -57,9 +53,9 @@ describe("Pronunciations", () => { @@ -82,7 +78,7 @@ describe("Pronunciations", () => { - + @@ -94,13 +90,13 @@ describe("Pronunciations", () => { test("style depends on pronunciations state", () => { const wordId = "1"; - const mockStore2 = createMockStore(mockRecordingState(wordId)); + const mockStore2 = configureMockStore()(mockRecordingState(wordId)); act(() => { testRenderer = create( - + diff --git a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx index 9e5f5b4ef4..a744f16753 100644 --- a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -5,22 +5,17 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; +import { defaultState } from "components/App/DefaultState"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; -import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import theme from "types/theme"; - -// Mock the audio components -jest - .spyOn(window.HTMLMediaElement.prototype, "pause") - .mockImplementation(() => {}); -jest.mock("components/Pronunciations/Recorder"); +import { newPronunciation } from "types/word"; // Test variables let testRenderer: ReactTestRenderer; -const mockAudio = ["a.wav", "b.wav"]; -const mockStore = configureMockStore()({ pronunciationsState }); +const mockAudio = ["a.wav", "b.wav"].map((f) => newPronunciation(f)); +const mockStore = configureMockStore()(defaultState); const renderPronunciationsBackend = async ( withRecord: boolean @@ -31,10 +26,11 @@ const renderPronunciationsBackend = async ( diff --git a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx index b94b0da90d..e1f95e8c0c 100644 --- a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx @@ -5,33 +5,29 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; +import { defaultState } from "components/App/DefaultState"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; -import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import theme from "types/theme"; - -// Mock the audio components -jest - .spyOn(window.HTMLMediaElement.prototype, "pause") - .mockImplementation(() => {}); -jest.mock("components/Pronunciations/Recorder"); +import { newPronunciation } from "types/word"; // Test variables let testRenderer: renderer.ReactTestRenderer; -const mockStore = configureMockStore()({ pronunciationsState }); +const mockStore = configureMockStore()(defaultState); describe("PronunciationsFrontend", () => { it("renders with record button and play buttons", () => { - const audio = ["a.wav", "b.wav"]; + const audio = ["a.wav", "b.wav"].map((f) => newPronunciation(f)); renderer.act(() => { testRenderer = renderer.create( diff --git a/src/components/Pronunciations/utilities.ts b/src/components/Pronunciations/utilities.ts index 08761c38a0..3f43592ff9 100644 --- a/src/components/Pronunciations/utilities.ts +++ b/src/components/Pronunciations/utilities.ts @@ -1,4 +1,6 @@ +import { Pronunciation } from "api"; import { uploadAudio } from "backend"; +import { FileWithSpeakerId } from "types/word"; /** Generate a timestamp-based file name for the given `wordId`. */ export function getFileNameForWord(wordId: string): string { @@ -9,20 +11,21 @@ export function getFileNameForWord(wordId: string): string { return compressed.join("") + "_" + new Date().getTime().toString(36); } -/** Given an audio file `url` that was generated with `URL.createObjectURL()`, +/** Given a pronunciation with .fileName generated by `URL.createObjectURL()`, * add that audio file to the word with the given `wordId`. * Return the id of the updated word. */ -export async function uploadFileFromUrl( +export async function uploadFileFromPronunciation( wordId: string, - url: string + audio: Pronunciation ): Promise { - const audioBlob = await fetch(url).then((result) => result.blob()); - const fileName = getFileNameForWord(wordId); - const audioFile = new File([audioBlob], fileName, { + const { fileName, speakerId } = audio; + const audioBlob = await fetch(fileName).then((result) => result.blob()); + const file = new File([audioBlob], getFileNameForWord(wordId), { type: audioBlob.type, lastModified: Date.now(), }); - const newId = await uploadAudio(wordId, audioFile); - URL.revokeObjectURL(url); + (file as FileWithSpeakerId).speakerId = speakerId; + const newId = await uploadAudio(wordId, file); + URL.revokeObjectURL(fileName); return newId; } diff --git a/src/components/UserSettings/AvatarUpload.tsx b/src/components/UserSettings/AvatarUpload.tsx deleted file mode 100644 index 55f8c562e6..0000000000 --- a/src/components/UserSettings/AvatarUpload.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Grid, Typography } from "@mui/material"; -import React, { ReactElement, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { uploadAvatar } from "backend"; -import { getUserId } from "backend/localStorage"; -import { FileInputButton, LoadingDoneButton } from "components/Buttons"; - -interface AvatarUploadProps { - doneCallback?: () => void; -} - -/** - * Allows the current user to select an image and upload as their avatar - */ -export default function AvatarUpload(props: AvatarUploadProps): ReactElement { - const [file, setFile] = useState(); - const [filename, setFilename] = useState(); - const [loading, setLoading] = useState(false); - const [done, setDone] = useState(false); - const { t } = useTranslation(); - - function updateFile(file: File): void { - if (file) { - setFile(file); - setFilename(file.name); - } - } - - async function upload(e: React.FormEvent): Promise { - e.preventDefault(); - e.stopPropagation(); - if (file) { - setLoading(true); - await uploadAvatar(getUserId(), file) - .then(onDone) - .catch(() => setLoading(false)); - } - } - - async function onDone(): Promise { - setDone(true); - if (props.doneCallback) { - setTimeout(props.doneCallback, 500); - } - } - - return ( -
upload(e)}> - {/* Displays the name of the selected file */} - {filename && ( - - {t("createProject.fileSelected")}: {filename} - - )} - - - updateFile(file)} - accept="image/*" - > - {t("buttons.browse")} - - - - - {t("buttons.save")} - - - -
- ); -} diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index 3131234f9c..3d0ca53176 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -78,12 +78,7 @@ export default function WordCard(props: WordCardProps): ReactElement { {full && ( <> {audio.length > 0 && ( - {}} - playerOnly - pronunciationFiles={audio} - wordId={id} - /> + )} {!!note.text && (
diff --git a/src/components/WordCard/tests/index.test.tsx b/src/components/WordCard/tests/index.test.tsx index 6e44038e6a..4690bebf31 100644 --- a/src/components/WordCard/tests/index.test.tsx +++ b/src/components/WordCard/tests/index.test.tsx @@ -6,7 +6,7 @@ import { Word } from "api/models"; import WordCard, { AudioSummary, buttonIdFull } from "components/WordCard"; import SenseCard from "components/WordCard/SenseCard"; import SummarySenseCard from "components/WordCard/SummarySenseCard"; -import { newSense, newWord } from "types/word"; +import { newPronunciation, newSense, newWord } from "types/word"; // Mock the audio components jest @@ -18,7 +18,8 @@ jest.mock("components/Pronunciations/Recorder"); const mockWordId = "mock-id"; const buttonId = buttonIdFull(mockWordId); const mockWord: Word = { ...newWord(), id: mockWordId }; -mockWord.audio.push("song", "speech", "rap", "poem"); +const newAudio = ["song", "rap", "poem"].map((f) => newPronunciation(f)); +mockWord.audio.push(...newAudio); mockWord.senses.push(newSense(), newSense()); let cardHandle: ReactTestRenderer; diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index 500072570c..f8e002b989 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -1,12 +1,12 @@ import { Action, PayloadAction } from "@reduxjs/toolkit"; -import { Sense, Word } from "api/models"; +import { Pronunciation, Sense, Word } from "api/models"; import * as backend from "backend"; import { addEntryEditToGoal, asyncUpdateGoal, } from "components/GoalTimeline/Redux/GoalActions"; -import { uploadFileFromUrl } from "components/Pronunciations/utilities"; +import { uploadFileFromPronunciation } from "components/Pronunciations/utilities"; import { deleteWordAction, resetReviewEntriesAction, @@ -20,7 +20,12 @@ import { ReviewEntriesWord, } from "goals/ReviewEntries/ReviewEntriesTypes"; import { StoreStateDispatch } from "types/Redux/actions"; -import { newNote, newSense } from "types/word"; +import { + FileWithSpeakerId, + newNote, + newSense, + updateSpeakerInAudio, +} from "types/word"; // Action Creation Functions @@ -156,14 +161,6 @@ export function updateFrontierWord( } const oldId = editSource.id; - // Set aside audio changes for last. - const delAudio = oldData.audio.filter( - (o) => !newData.audio.find((n) => n === o) - ); - const addAudio = [...(newData.audioNew ?? [])]; - editSource.audio = oldData.audio; - delete editSource.audioNew; - // Get the original word, for updating. const editWord = await backend.getWord(oldId); @@ -175,15 +172,24 @@ export function updateFrontierWord( editWord.note = newNote(editSource.noteText, editWord.note?.language); editWord.flag = { ...editSource.flag }; + // Apply any speakerId changes, but save adding/deleting audio for later. + editWord.audio = oldData.audio.map( + (o) => newData.audio.find((n) => n.fileName === o.fileName) ?? o + ); + const delAudio = oldData.audio.filter( + (o) => !newData.audio.find((n) => n.fileName === o.fileName) + ); + const addAudio = [...(newData.audioNew ?? [])]; + // Update the word in the backend, and retrieve the id. let newId = (await backend.updateWord(editWord)).id; - // Add/remove audio. - for (const url of addAudio) { - newId = await uploadFileFromUrl(newId, url); + // Add/delete audio. + for (const audio of addAudio) { + newId = await uploadFileFromPronunciation(newId, audio); } - for (const fileName of delAudio) { - newId = await backend.deleteAudio(newId, fileName); + for (const audio of delAudio) { + newId = await backend.deleteAudio(newId, audio.fileName); } // Update the word in the state. @@ -229,11 +235,22 @@ export function deleteAudio( ); } +export function replaceAudio( + wordId: string, + pro: Pronunciation +): (dispatch: StoreStateDispatch) => Promise { + return asyncRefreshWord(wordId, async (oldId: string) => { + const word = await backend.getWord(oldId); + const audio = updateSpeakerInAudio(word.audio, pro); + return audio ? (await backend.updateWord({ ...word, audio })).id : oldId; + }); +} + export function uploadAudio( wordId: string, - audioFile: File + file: FileWithSpeakerId ): (dispatch: StoreStateDispatch) => Promise { return asyncRefreshWord(wordId, (wordId: string) => - backend.uploadAudio(wordId, audioFile) + backend.uploadAudio(wordId, file) ); } diff --git a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx index 0fec2339af..9380aae120 100644 --- a/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx +++ b/src/goals/ReviewEntries/Redux/tests/ReviewEntriesActions.test.tsx @@ -1,16 +1,19 @@ import { PreloadedState } from "redux"; -import { Sense, Word } from "api/models"; +import { Pronunciation, Sense, Word } from "api/models"; import { defaultState } from "components/App/DefaultState"; import { + deleteAudio, deleteWord, getSenseError, getSenseFromEditSense, + replaceAudio, resetReviewEntries, setAllWords, setSortBy, updateFrontierWord, updateWord, + uploadAudio, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { ColumnId, @@ -19,20 +22,29 @@ import { } from "goals/ReviewEntries/ReviewEntriesTypes"; import { RootState, setupStore } from "store"; import { newSemanticDomain } from "types/semanticDomain"; -import { newFlag, newGloss, newNote, newSense, newWord } from "types/word"; +import { + newFlag, + newGloss, + newNote, + newPronunciation, + newSense, + newWord, +} from "types/word"; import { Bcp47Code } from "types/writingSystem"; +const mockDeleteAudio = jest.fn(); const mockGetWord = jest.fn(); const mockUpdateWord = jest.fn(); +const mockUploadAudio = jest.fn(); function mockGetWordResolve(data: Word): void { mockGetWord.mockResolvedValue(JSON.parse(JSON.stringify(data))); } jest.mock("backend", () => ({ - deleteAudio: () => jest.fn(), + deleteAudio: (args: any[]) => mockDeleteAudio(...args), getWord: (wordId: string) => mockGetWord(wordId), updateWord: (word: Word) => mockUpdateWord(word), - uploadAudio: () => jest.fn(), + uploadAudio: (args: any[]) => mockUploadAudio(...args), })); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ addEntryEditToGoal: () => jest.fn(), @@ -178,6 +190,99 @@ describe("ReviewEntriesActions", () => { }); }); + describe("asyncRefreshWord", () => { + test("deleteAudio", async () => { + // Setup state with word with audio + const fileName = "audio-file-name"; + const oldWord: Word = { + ...mockFrontierWord(), + audio: [newPronunciation(fileName)], + }; + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { sortBy: colId, words: [oldWord] }, + }); + + // Prep replacement word without the audio + const newId = "id-after-audio-deleted"; + const word: Word = { ...oldWord, audio: [], id: newId }; + + // Mock backend function that will be called + mockDeleteAudio.mockResolvedValueOnce(newId); + mockGetWord.mockResolvedValueOnce(word); + + // Dispatch the audio delete + await store.dispatch(deleteAudio(wordId, fileName)); + expect(mockDeleteAudio).toHaveBeenCalledTimes(1); + + // Verify the replacement word in state has the audio removed + const words = store.getState().reviewEntriesState.words; + expect(words.find((w) => w.id === wordId)).toBeNull; + const wordInState = words.find((w) => w.id === newId); + expect(wordInState?.audio).toHaveLength(0); + }); + + test("replaceAudio", async () => { + // Setup state with word with audio + const oldPro = newPronunciation("audio-file-name"); + const oldWord: Word = { ...mockFrontierWord(), audio: [oldPro] }; + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { sortBy: colId, words: [oldWord] }, + }); + + // Prep replacement word with a new speaker on the audio + const newId = "id-after-audio-replaced"; + const speakerId = "id-of-audio-speaker"; + const pro: Pronunciation = { ...oldPro, speakerId }; + const word: Word = { ...oldWord, audio: [pro], id: newId }; + + // Mock backend function that will be called + mockGetWord.mockResolvedValueOnce(oldWord).mockResolvedValueOnce(word); + mockUpdateWord.mockResolvedValueOnce(newId); + + // Dispatch the audio replace + await store.dispatch(replaceAudio(wordId, pro)); + expect(mockUpdateWord).toHaveBeenCalledTimes(1); + + // Verify the replacement word in state has the updated speaker id + const words = store.getState().reviewEntriesState.words; + expect(words.find((w) => w.id === wordId)).toBeNull; + const audioInState = words.find((w) => w.id === newId)?.audio; + expect(audioInState).toHaveLength(1); + expect(audioInState![0].speakerId).toEqual(speakerId); + }); + + test("uploadAudio", async () => { + // Setup state with word without audio + const pro = newPronunciation("audio-file-name"); + const oldWord = mockFrontierWord(); + const store = setupStore({ + ...persistedDefaultState, + reviewEntriesState: { sortBy: colId, words: [oldWord] }, + }); + + // Prep replacement word with audio added + const newId = "id-after-audio-uploaded"; + const word: Word = { ...oldWord, audio: [pro], id: newId }; + + // Mock backend function that will be called + mockUploadAudio.mockResolvedValueOnce(newId); + mockGetWord.mockResolvedValueOnce(word); + + // Dispatch the audio upload + await store.dispatch(uploadAudio(wordId, new File([], pro.fileName))); + expect(mockUploadAudio).toHaveBeenCalledTimes(1); + + // Verify the replacement word in state has the audio added + const words = store.getState().reviewEntriesState.words; + expect(words.find((w) => w.id === wordId)).toBeNull; + const audioInState = words.find((w) => w.id === newId)?.audio; + expect(audioInState).toHaveLength(1); + expect(audioInState![0].fileName).toEqual(pro.fileName); + }); + }); + describe("updateFrontierWord", () => { beforeEach(() => { mockUpdateWord.mockResolvedValue(newWord()); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx index 566fb133a0..637c613906 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx @@ -2,7 +2,7 @@ import { Column } from "@material-table/core"; import { Input, Typography } from "@mui/material"; import { t } from "i18next"; -import { SemanticDomain } from "api/models"; +import { Pronunciation, SemanticDomain } from "api/models"; import { DefinitionCell, DeleteCell, @@ -21,6 +21,11 @@ import { ReviewEntriesWord, ReviewEntriesWordField, } from "goals/ReviewEntries/ReviewEntriesTypes"; +import { + FileWithSpeakerId, + newPronunciation, + updateSpeakerInAudio, +} from "types/word"; import { compareFlags } from "utilities/wordUtilities"; export class ColumnTitle { @@ -357,21 +362,18 @@ const columns: Column[] = [ field: ReviewEntriesWordField.Pronunciations, filterPlaceholder: "#", render: (rowData: ReviewEntriesWord) => ( - + ), editComponent: (props: FieldParameterStandard) => ( { + addNewAudio: (file: FileWithSpeakerId): void => { props.onRowDataChange && props.onRowDataChange({ ...props.rowData, audioNew: [ ...(props.rowData.audioNew ?? []), - URL.createObjectURL(file), + newPronunciation(URL.createObjectURL(file), file.speakerId), ], }); }, @@ -379,19 +381,42 @@ const columns: Column[] = [ props.onRowDataChange && props.onRowDataChange({ ...props.rowData, - audioNew: props.rowData.audioNew?.filter((u) => u !== url), + audioNew: props.rowData.audioNew?.filter( + (a) => a.fileName !== url + ), }); }, + repNewAudio: (pro: Pronunciation): void => { + if (props.onRowDataChange && props.rowData.audioNew) { + const audioNew = updateSpeakerInAudio( + props.rowData.audioNew, + pro + ); + if (audioNew) { + props.onRowDataChange({ ...props.rowData, audioNew }); + } + } + }, delOldAudio: (fileName: string): void => { props.onRowDataChange && props.onRowDataChange({ ...props.rowData, - audio: props.rowData.audio.filter((f) => f !== fileName), + audio: props.rowData.audio.filter( + (a) => a.fileName !== fileName + ), }); }, + repOldAudio: (pro: Pronunciation): void => { + if (props.onRowDataChange) { + const audio = updateSpeakerInAudio(props.rowData.audio, pro); + if (audio) { + props.onRowDataChange({ ...props.rowData, audio }); + } + } + }, }} - pronunciationFiles={props.rowData.audio} - pronunciationsNew={props.rowData.audioNew} + audio={props.rowData.audio} + audioNew={props.rowData.audioNew} wordId={props.rowData.id} /> ), diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx index 001727e8fa..6cc5dacd98 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx @@ -1,21 +1,26 @@ import { ReactElement } from "react"; +import { Pronunciation } from "api/models"; import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; import { deleteAudio, + replaceAudio, uploadAudio, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { useAppDispatch } from "types/hooks"; +import { FileWithSpeakerId } from "types/word"; interface PronunciationsCellProps { audioFunctions?: { - addNewAudio: (file: File) => void; + addNewAudio: (file: FileWithSpeakerId) => void; delNewAudio: (url: string) => void; + repNewAudio: (audio: Pronunciation) => void; delOldAudio: (fileName: string) => void; + repOldAudio: (audio: Pronunciation) => void; }; - pronunciationFiles: string[]; - pronunciationsNew?: string[]; + audio: Pronunciation[]; + audioNew?: Pronunciation[]; wordId: string; } @@ -25,31 +30,34 @@ export default function PronunciationsCell( const dispatch = useAppDispatch(); const dispatchDelete = (fileName: string): Promise => dispatch(deleteAudio(props.wordId, fileName)); - const dispatchUpload = (audioFile: File): Promise => - dispatch(uploadAudio(props.wordId, audioFile)); - - const { addNewAudio, delNewAudio, delOldAudio } = props.audioFunctions ?? {}; + const dispatchReplace = (audio: Pronunciation): Promise => + dispatch(replaceAudio(props.wordId, audio)); + const dispatchUpload = (file: FileWithSpeakerId): Promise => + dispatch(uploadAudio(props.wordId, file)); return props.audioFunctions ? ( } - pronunciationFiles={props.pronunciationsNew ?? []} - deleteAudio={delNewAudio!} - uploadAudio={addNewAudio!} + audio={props.audioNew ?? []} + deleteAudio={props.audioFunctions.delNewAudio} + replaceAudio={props.audioFunctions.repNewAudio} + uploadAudio={props.audioFunctions.addNewAudio} /> ) : ( ); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx index 671c5249a7..cf93ffe7d3 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx @@ -5,17 +5,15 @@ import configureMockStore from "redux-mock-store"; import "tests/reactI18nextMock"; +import { Pronunciation } from "api/models"; +import { defaultState as currentProjectState } from "components/Project/ProjectReduxTypes"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import { defaultState as pronunciationsState } from "components/Pronunciations/Redux/PronunciationsReduxTypes"; import PronunciationsCell from "goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell"; +import { StoreState } from "types"; import theme from "types/theme"; - -// Mock the audio components -jest - .spyOn(window.HTMLMediaElement.prototype, "pause") - .mockImplementation(() => {}); -jest.mock("components/Pronunciations/Recorder"); +import { newPronunciation } from "types/word"; // Mock the store interactions jest.mock("goals/ReviewEntries/Redux/ReviewEntriesActions", () => ({ @@ -31,32 +29,40 @@ jest.mock("types/hooks", () => { const mockDeleteAudio = jest.fn(); const mockUploadAudio = jest.fn(); const mockDispatch = jest.fn(); -const mockStore = configureMockStore()({ pronunciationsState }); +const mockState: Partial = { + currentProjectState, + pronunciationsState, +}; +const mockStore = configureMockStore()(mockState); // Mock the functions used for the component in edit mode const mockAddNewAudio = jest.fn(); const mockDelNewAudio = jest.fn(); +const mockRepNewAudio = jest.fn(); const mockDelOldAudio = jest.fn(); +const mockRepOldAudio = jest.fn(); const mockAudioFunctions = { addNewAudio: (...args: any[]) => mockAddNewAudio(...args), delNewAudio: (...args: any[]) => mockDelNewAudio(...args), + repNewAudio: (...args: any[]) => mockRepNewAudio(...args), delOldAudio: (...args: any[]) => mockDelOldAudio(...args), + repOldAudio: (...args: any[]) => mockRepOldAudio(...args), }; // Render the cell component with a store and theme let testRenderer: ReactTestRenderer; const renderPronunciationsCell = async ( - pronunciationFiles: string[], - pronunciationsNew?: string[] + audio: Pronunciation[], + audioNew?: Pronunciation[] ): Promise => { await act(async () => { testRenderer = create( @@ -72,7 +78,7 @@ beforeEach(() => { describe("PronunciationsCell", () => { describe("not in edit mode", () => { it("renders", async () => { - const mockAudio = ["1", "2", "3"]; + const mockAudio = ["1", "2", "3"].map((f) => newPronunciation(f)); await renderPronunciationsCell(mockAudio); const playButtons = testRenderer.root.findAllByType(AudioPlayer); expect(playButtons).toHaveLength(mockAudio.length); @@ -81,7 +87,7 @@ describe("PronunciationsCell", () => { }); it("has player that dispatches action", async () => { - await renderPronunciationsCell(["1"]); + await renderPronunciationsCell([newPronunciation("1")]); await act(async () => { testRenderer.root.findByType(AudioPlayer).props.deleteAudio(); }); @@ -104,8 +110,8 @@ describe("PronunciationsCell", () => { describe("in edit mode", () => { it("renders", async () => { - const mockAudioOld = ["1", "2", "3", "4"]; - const mockAudioNew = ["5", "6"]; + const mockAudioOld = ["1", "2", "3", "4"].map((f) => newPronunciation(f)); + const mockAudioNew = ["5", "6"].map((f) => newPronunciation(f)); await renderPronunciationsCell(mockAudioOld, mockAudioNew); const playButtons = testRenderer.root.findAllByType(AudioPlayer); expect(playButtons).toHaveLength( @@ -116,7 +122,10 @@ describe("PronunciationsCell", () => { }); it("has players that call prop functions", async () => { - await renderPronunciationsCell(["old"], ["new"]); + await renderPronunciationsCell( + [newPronunciation("old")], + [newPronunciation("new")] + ); const playButtons = testRenderer.root.findAllByType(AudioPlayer); // player for audio present prior to row edit diff --git a/src/goals/ReviewEntries/ReviewEntriesTypes.ts b/src/goals/ReviewEntries/ReviewEntriesTypes.ts index b7a7be4ce8..faac50dc82 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTypes.ts +++ b/src/goals/ReviewEntries/ReviewEntriesTypes.ts @@ -3,6 +3,7 @@ import { Flag, Gloss, GrammaticalInfo, + Pronunciation, SemanticDomain, Sense, Status, @@ -54,8 +55,8 @@ export class ReviewEntriesWord { id: string; vernacular: string; senses: ReviewEntriesSense[]; - audio: string[]; - audioNew?: string[]; + audio: Pronunciation[]; + audioNew?: Pronunciation[]; noteText: string; flag: Flag; protected: boolean; diff --git a/src/goals/ReviewEntries/tests/index.test.tsx b/src/goals/ReviewEntries/tests/index.test.tsx index 9bacb207ec..491a28ebd6 100644 --- a/src/goals/ReviewEntries/tests/index.test.tsx +++ b/src/goals/ReviewEntries/tests/index.test.tsx @@ -37,8 +37,6 @@ jest.mock("uuid", () => ({ v4: () => mockUuid() })); jest.mock("backend", () => ({ getFrontierWords: (...args: any[]) => mockGetFrontierWords(...args), })); -// Mock the node module used by AudioRecorder. -jest.mock("components/Pronunciations/Recorder"); jest.mock("components/TreeView", () => "div"); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({})); jest.mock("types/hooks", () => ({ diff --git a/src/setupTests.js b/src/setupTests.js index 4558812d92..4b65d92373 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -5,3 +5,9 @@ global.console.error = (message) => { global.console.warn = (message) => { throw message; }; + +// Mock the audio components +jest + .spyOn(window.HTMLMediaElement.prototype, "pause") + .mockImplementation(() => {}); +jest.mock("components/Pronunciations/Recorder"); diff --git a/src/types/project.ts b/src/types/project.ts index ab3632aa08..c0d70abf89 100644 --- a/src/types/project.ts +++ b/src/types/project.ts @@ -1,4 +1,4 @@ -import { AutocompleteSetting, Project } from "api/models"; +import { AutocompleteSetting, ConsentType, Project, Speaker } from "api/models"; import { newWritingSystem } from "types/writingSystem"; import { randomIntString } from "utilities/utilities"; @@ -28,3 +28,12 @@ export function randomProject(): Project { project.isActive = Math.random() < 0.5; return project; } + +export function randomSpeaker(): Speaker { + return { + id: randomIntString(), + projectId: randomIntString(), + name: randomIntString(), + consent: ConsentType.None, + }; +} diff --git a/src/types/word.ts b/src/types/word.ts index c82dd30191..b160306620 100644 --- a/src/types/word.ts +++ b/src/types/word.ts @@ -7,6 +7,7 @@ import { GramCatGroup, GrammaticalInfo, Note, + Pronunciation, SemanticDomain, Sense, Status, @@ -14,6 +15,34 @@ import { } from "api/models"; import { randomIntString } from "utilities/utilities"; +export interface FileWithSpeakerId extends File { + speakerId?: string; +} + +export function newPronunciation(fileName = "", speakerId = ""): Pronunciation { + return { fileName, speakerId, protected: false }; +} + +/** Returns a copy of the audio array with every entry updated that has: + * - .protected false; + * - same .fileName as the update pronunciation; and + * - different .speakerId than the update pronunciation. + * + * Returns undefined if no such entry in the array. */ +export function updateSpeakerInAudio( + audio: Pronunciation[], + update: Pronunciation +): Pronunciation[] | undefined { + const updatePredicate = (p: Pronunciation): boolean => + !p.protected && + p.fileName === update.fileName && + p.speakerId !== update.speakerId; + if (audio.findIndex(updatePredicate) === -1) { + return; + } + return audio.map((a) => (updatePredicate(a) ? update : a)); +} + export function newDefinition(text = "", language = ""): Definition { return { text, language }; }