From 5e0f72dda1dd8530b33443feb8342656be2d77ee Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 16 Nov 2023 14:05:19 -0500 Subject: [PATCH 01/56] Add Backend model/repo/controller for speaker --- Backend.Tests/Mocks/SpeakerRepositoryMock.cs | 81 ++ Backend.Tests/Models/SpeakerTests.cs | 78 ++ Backend/Controllers/SpeakerController.cs | 178 +++ Backend/Interfaces/ISpeakerContext.cs | 10 + Backend/Interfaces/ISpeakerRepository.cs | 17 + Backend/Models/Speaker.cs | 109 ++ Backend/Repositories/SpeakerRepository.cs | 92 ++ src/api/.openapi-generator/FILES | 4 + src/api/api.ts | 1 + src/api/api/speaker-api.ts | 1012 ++++++++++++++++++ src/api/models/consent-type.ts | 23 + src/api/models/consent.ts | 35 + src/api/models/index.ts | 3 + src/api/models/speaker.ts | 47 + 14 files changed, 1690 insertions(+) create mode 100644 Backend.Tests/Mocks/SpeakerRepositoryMock.cs create mode 100644 Backend.Tests/Models/SpeakerTests.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 100644 src/api/api/speaker-api.ts create mode 100644 src/api/models/consent-type.ts create mode 100644 src/api/models/consent.ts create mode 100644 src/api/models/speaker.ts 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..477696c5c8 --- /dev/null +++ b/Backend.Tests/Models/SpeakerTests.cs @@ -0,0 +1,78 @@ +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"; + private readonly Consent _consent = new() { FileName = FileName, FileType = ConsentType.Audio }; + + [Test] + public void TestClone() + { + var speakerA = new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = _consent }; + Assert.That(speakerA.Equals(speakerA.Clone()), Is.True); + } + + [Test] + public void TestEquals() + { + var speaker = new Speaker { Name = Name, Consent = _consent }; + Assert.That(speaker.Equals(null), Is.False); + Assert.That(new Speaker { Id = "diff-id", ProjectId = ProjectId, Name = Name, Consent = _consent } + .Equals(speaker), Is.False); + Assert.That(new Speaker { Id = Id, ProjectId = "diff-proj-id", Name = Name, Consent = _consent } + .Equals(speaker), Is.False); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = "Mr. Different", Consent = _consent } + .Equals(speaker), Is.False); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = new() } + .Equals(speaker), Is.False); + } + + [Test] + public void TestHashCode() + { + var code = new Speaker { Name = Name, Consent = _consent }.GetHashCode(); + Assert.That(new Speaker { Id = "diff-id", ProjectId = ProjectId, Name = Name, Consent = _consent } + .GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Speaker { Id = Id, ProjectId = "diff-proj-id", Name = Name, Consent = _consent } + .GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = "Mr. Different", Consent = _consent } + .GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = new() } + .GetHashCode(), Is.Not.EqualTo(code)); + } + } + + public class ConsentTests + { + private const string FileName = "audio.mp3"; + private readonly Consent _consent = new() { FileName = FileName, FileType = ConsentType.Audio }; + + [Test] + public void TestClone() + { + Assert.That(_consent.Equals(_consent.Clone()), Is.True); + } + + [Test] + public void TestEquals() + { + Assert.That(_consent.Equals(null), Is.False); + Assert.That(new Consent { FileName = "diff", FileType = ConsentType.Audio }.Equals(_consent), Is.False); + Assert.That(new Consent { FileName = FileName, FileType = ConsentType.Image }.Equals(_consent), Is.False); + } + + [Test] + public void TestHashCode() + { + var code = _consent.GetHashCode(); + Assert.That(new Consent { FileName = "diff", FileType = ConsentType.Audio }.GetHashCode(), Is.Not.EqualTo(code)); + Assert.That(new Consent { FileName = FileName, FileType = ConsentType.Image }.GetHashCode(), Is.Not.EqualTo(code)); + } + } +} diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs new file mode 100644 index 0000000000..587526fd22 --- /dev/null +++ b/Backend/Controllers/SpeakerController.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +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; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +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 + [HttpPost(Name = "CreateSpeaker")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] + public async Task CreateSpeaker(string projectId, [FromBody, BindRequired] Speaker speaker) + { + // Check permissions + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.DeleteEditSettingsAndUsers, projectId)) + { + return Forbid(); + } + + // Create speaker and return id + speaker.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)); + } + + /// Updates the for the specified projectId and speakerId + /// Id of updated Speaker + [HttpPut("{speakerId}", Name = "UpdateSpeaker")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] + public async Task UpdateSpeaker( + string projectId, string speakerId, [FromBody, BindRequired] Speaker speaker) + { + // Check permissions + if (!await _permissionService.HasProjectPermission( + HttpContext, Permission.DeleteEditSettingsAndUsers, projectId)) + { + return Forbid(); + } + + // Update speaker and return result + speaker.Id = speakerId; + speaker.ProjectId = projectId; + 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("{speakerId}/changename/{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 + speaker.Name = name; + return await _speakerRepo.Update(speakerId, speaker) switch + { + ResultOfUpdate.NotFound => NotFound(speakerId), + ResultOfUpdate.Updated => Ok(speakerId), + _ => StatusCode(StatusCodes.Status304NotModified, speakerId) + }; + } + } +} 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..b42e418011 --- /dev/null +++ b/Backend/Models/Speaker.cs @@ -0,0 +1,109 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BackendFramework.Models +{ + /// + /// Helper object that contains a parent word and a number of children which will be merged into it + /// along with the userId of who made the merge and at what time + /// + 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 Consent Consent { get; set; } + + public Speaker() + { + Id = ""; + ProjectId = ""; + Name = ""; + Consent = new Consent(); + } + + public Speaker Clone() + { + return new Speaker + { + Id = Id, + ProjectId = ProjectId, + Name = Name, + Consent = Consent.Clone() + }; + } + + public bool ContentEquals(Speaker other) + { + return ProjectId.Equals(other.ProjectId, StringComparison.Ordinal) && + Name.Equals(other.Name, StringComparison.Ordinal) && + Consent.Equals(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 class Consent + { + [Required] + [BsonElement("fileName")] + public string? FileName { get; set; } + + [Required] + [BsonElement("fileType")] + public ConsentType? FileType { get; set; } + + public Consent Clone() + { + return new Consent + { + FileName = FileName, + FileType = FileType + }; + } + + private bool ContentEquals(Consent other) + { + return FileName == other.FileName && FileType == other.FileType; + } + + public override bool Equals(object? obj) + { + return obj is Consent other && GetType() == obj.GetType() && ContentEquals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(FileName, FileType); + } + } + + public enum ConsentType + { + Audio, + Image + } +} 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/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index bb7a6ee30f..330ce9e706 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,8 @@ index.ts models/autocomplete-setting.ts models/banner-type.ts models/chart-root-data.ts +models/consent-type.ts +models/consent.ts models/credentials.ts models/custom-field.ts models/dataset.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/speaker-api.ts b/src/api/api/speaker-api.ts new file mode 100644 index 0000000000..4b0446d73d --- /dev/null +++ b/src/api/api/speaker-api.ts @@ -0,0 +1,1012 @@ +/* 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} projectId + * @param {Speaker} speaker + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createSpeaker: async ( + projectId: string, + speaker: Speaker, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("createSpeaker", "projectId", projectId); + // verify required parameter 'speaker' is not null or undefined + assertParamExists("createSpeaker", "speaker", speaker); + 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: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + speaker, + localVarRequestOptions, + configuration + ); + + 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} 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 {Speaker} speaker + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateSpeaker: async ( + projectId: string, + speakerId: string, + speaker: Speaker, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("updateSpeaker", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("updateSpeaker", "speakerId", speakerId); + // verify required parameter 'speaker' is not null or undefined + assertParamExists("updateSpeaker", "speaker", speaker); + 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: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + speaker, + localVarRequestOptions, + configuration + ); + + 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/{speakerId}/changename/{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, + }; + }, + }; +}; + +/** + * SpeakerApi - functional programming interface + * @export + */ +export const SpeakerApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = SpeakerApiAxiosParamCreator(configuration); + return { + /** + * + * @param {string} projectId + * @param {Speaker} speaker + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createSpeaker( + projectId: string, + speaker: Speaker, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.createSpeaker( + projectId, + speaker, + 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} 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 {Speaker} speaker + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateSpeaker( + projectId: string, + speakerId: string, + speaker: Speaker, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateSpeaker( + projectId, + speakerId, + speaker, + 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 + ); + }, + }; +}; + +/** + * SpeakerApi - factory interface + * @export + */ +export const SpeakerApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = SpeakerApiFp(configuration); + return { + /** + * + * @param {string} projectId + * @param {Speaker} speaker + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createSpeaker( + projectId: string, + speaker: Speaker, + options?: any + ): AxiosPromise { + return localVarFp + .createSpeaker(projectId, speaker, 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} 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 {Speaker} speaker + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateSpeaker( + projectId: string, + speakerId: string, + speaker: Speaker, + options?: any + ): AxiosPromise { + return localVarFp + .updateSpeaker(projectId, speakerId, speaker, 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)); + }, + }; +}; + +/** + * Request parameters for createSpeaker operation in SpeakerApi. + * @export + * @interface SpeakerApiCreateSpeakerRequest + */ +export interface SpeakerApiCreateSpeakerRequest { + /** + * + * @type {string} + * @memberof SpeakerApiCreateSpeaker + */ + readonly projectId: string; + + /** + * + * @type {Speaker} + * @memberof SpeakerApiCreateSpeaker + */ + readonly speaker: Speaker; +} + +/** + * 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 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 updateSpeaker operation in SpeakerApi. + * @export + * @interface SpeakerApiUpdateSpeakerRequest + */ +export interface SpeakerApiUpdateSpeakerRequest { + /** + * + * @type {string} + * @memberof SpeakerApiUpdateSpeaker + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUpdateSpeaker + */ + readonly speakerId: string; + + /** + * + * @type {Speaker} + * @memberof SpeakerApiUpdateSpeaker + */ + readonly speaker: Speaker; +} + +/** + * 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; +} + +/** + * 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.projectId, + requestParameters.speaker, + 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 {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 {SpeakerApiUpdateSpeakerRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public updateSpeaker( + requestParameters: SpeakerApiUpdateSpeakerRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .updateSpeaker( + requestParameters.projectId, + requestParameters.speakerId, + requestParameters.speaker, + 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)); + } +} diff --git a/src/api/models/consent-type.ts b/src/api/models/consent-type.ts new file mode 100644 index 0000000000..15f1d7b059 --- /dev/null +++ b/src/api/models/consent-type.ts @@ -0,0 +1,23 @@ +/* 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 { + Audio = "Audio", + Image = "Image", +} diff --git a/src/api/models/consent.ts b/src/api/models/consent.ts new file mode 100644 index 0000000000..18f5f14f17 --- /dev/null +++ b/src/api/models/consent.ts @@ -0,0 +1,35 @@ +/* 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 Consent + */ +export interface Consent { + /** + * + * @type {string} + * @memberof Consent + */ + fileName: string; + /** + * + * @type {ConsentType} + * @memberof Consent + */ + fileType: ConsentType; +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 84464055aa..ed70f66d52 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,6 +1,8 @@ export * from "./autocomplete-setting"; export * from "./banner-type"; export * from "./chart-root-data"; +export * from "./consent"; +export * from "./consent-type"; export * from "./credentials"; export * from "./custom-field"; export * from "./dataset"; @@ -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/speaker.ts b/src/api/models/speaker.ts new file mode 100644 index 0000000000..38e72df6a0 --- /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 { Consent } from "./consent"; + +/** + * + * @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 {Consent} + * @memberof Speaker + */ + consent: Consent; +} From 9799ebbd185138746b1732784c4283beda62365a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 16 Nov 2023 17:11:39 -0500 Subject: [PATCH 02/56] Add audio consent api; Add speaker to project state --- Backend/Controllers/SpeakerController.cs | 105 +++++- src/api/api/speaker-api.ts | 328 ++++++++++++++---- src/backend/index.ts | 65 ++++ src/components/Project/ProjectActions.ts | 11 +- src/components/Project/ProjectReducer.ts | 12 +- src/components/Project/ProjectReduxTypes.ts | 3 +- .../Project/tests/ProjectActions.test.tsx | 33 +- 7 files changed, 453 insertions(+), 104 deletions(-) diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index 587526fd22..b558a5502b 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using IO = System.IO; using System.Threading.Tasks; using BackendFramework.Helper; using BackendFramework.Interfaces; @@ -6,7 +7,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; namespace BackendFramework.Controllers { @@ -81,9 +81,9 @@ public async Task GetSpeaker(string projectId, string speakerId) /// Creates a for the specified projectId /// Id of created Speaker - [HttpPost(Name = "CreateSpeaker")] + [HttpGet("/create/{name}", Name = "CreateSpeaker")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] - public async Task CreateSpeaker(string projectId, [FromBody, BindRequired] Speaker speaker) + public async Task CreateSpeaker(string projectId, string name) { // Check permissions if (!await _permissionService.HasProjectPermission( @@ -93,7 +93,7 @@ public async Task CreateSpeaker(string projectId, [FromBody, Bind } // Create speaker and return id - speaker.ProjectId = projectId; + var speaker = new Speaker { Name = name, ProjectId = projectId }; return Ok((await _speakerRepo.Create(speaker)).Id); } @@ -120,12 +120,11 @@ public async Task DeleteSpeaker(string projectId, string speakerI return Ok(await _speakerRepo.Delete(projectId, speakerId)); } - /// Updates the for the specified projectId and speakerId + /// Removes consent of the for specified projectId and speakerId /// Id of updated Speaker - [HttpPut("{speakerId}", Name = "UpdateSpeaker")] + [HttpGet("removeconsent/{speakerId}", Name = "RemoveConsent")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] - public async Task UpdateSpeaker( - string projectId, string speakerId, [FromBody, BindRequired] Speaker speaker) + public async Task RemoveConsent(string projectId, string speakerId) { // Check permissions if (!await _permissionService.HasProjectPermission( @@ -134,9 +133,26 @@ public async Task UpdateSpeaker( return Forbid(); } - // Update speaker and return result - speaker.Id = speakerId; - speaker.ProjectId = projectId; + // Ensure the speaker exists + var speaker = await _speakerRepo.GetSpeaker(projectId, speakerId); + if (speaker is null) + { + return NotFound(speakerId); + } + + // Delete consent file + if (string.IsNullOrEmpty(speaker.Consent.FileName)) + { + return StatusCode(StatusCodes.Status304NotModified, speakerId); + } + var FilePath = FileStorage.GenerateAudioFilePath(projectId, speaker.Consent.FileName); + if (IO.File.Exists(FilePath)) + { + IO.File.Delete(FilePath); + } + + // Update speaker and return result with id + speaker.Consent = new(); return await _speakerRepo.Update(speakerId, speaker) switch { ResultOfUpdate.NotFound => NotFound(speakerId), @@ -147,7 +163,7 @@ public async Task UpdateSpeaker( /// Updates the 's name for the specified projectId and speakerId /// Id of updated Speaker - [HttpGet("{speakerId}/changename/{name}", Name = "UpdateSpeakerName")] + [HttpGet("update/{speakerId}/{name}", Name = "UpdateSpeakerName")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task UpdateSpeakerName(string projectId, string speakerId, string name) { @@ -165,7 +181,7 @@ public async Task UpdateSpeakerName(string projectId, string spea return NotFound(speakerId); } - // Update name and return result + // Update name and return result with id speaker.Name = name; return await _speakerRepo.Update(speakerId, speaker) switch { @@ -174,5 +190,68 @@ public async Task UpdateSpeakerName(string projectId, string spea _ => StatusCode(StatusCodes.Status304NotModified, speakerId) }; } + + /// + /// Adds an audio consent from + /// locally to ~/.CombineFiles/{ProjectId}/Import/ExtractedLocation/Lift/audio + /// and updates the of the specified + /// + /// Updated speaker + [HttpPost("uploadconsentaudio/{speakerId}", Name = "UploadConsentAudio")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] + public async Task UploadConsentAudio(string projectId, string speakerId, + [FromForm] FileUpload fileUpload) + { + // 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 = fileUpload.File; + if (file is null) + { + return BadRequest("Null File"); + } + if (file.Length == 0) + { + return BadRequest("Empty File"); + } + + // Copy file data to a local file with speakerId-dependent name + fileUpload.FilePath = FileStorage.GenerateAudioFilePathForWord(projectId, speakerId); + await using (var fs = new IO.FileStream(fileUpload.FilePath, IO.FileMode.Create)) + { + await file.CopyToAsync(fs); + } + + // Update speaker consent and return result with speaker + var fileName = IO.Path.GetFileName(fileUpload.FilePath); + speaker.Consent = new() { FileName = fileName, FileType = ConsentType.Audio }; + return await _speakerRepo.Update(speakerId, speaker) switch + { + ResultOfUpdate.NotFound => NotFound(speaker), + _ => Ok(speaker), + }; + } } } diff --git a/src/api/api/speaker-api.ts b/src/api/api/speaker-api.ts index 4b0446d73d..0f6551922d 100644 --- a/src/api/api/speaker-api.ts +++ b/src/api/api/speaker-api.ts @@ -48,23 +48,21 @@ export const SpeakerApiAxiosParamCreator = function ( return { /** * - * @param {string} projectId - * @param {Speaker} speaker + * @param {string} name + * @param {string} [projectId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ createSpeaker: async ( - projectId: string, - speaker: Speaker, + name: string, + projectId?: string, options: any = {} ): Promise => { - // verify required parameter 'projectId' is not null or undefined - assertParamExists("createSpeaker", "projectId", projectId); - // verify required parameter 'speaker' is not null or undefined - assertParamExists("createSpeaker", "speaker", speaker); - const localVarPath = `/v1/projects/{projectId}/speakers`.replace( - `{${"projectId"}}`, - encodeURIComponent(String(projectId)) + // 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); @@ -74,14 +72,16 @@ export const SpeakerApiAxiosParamCreator = function ( } const localVarRequestOptions = { - method: "POST", + method: "GET", ...baseOptions, ...options, }; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - localVarHeaderParameter["Content-Type"] = "application/json"; + if (projectId !== undefined) { + localVarQueryParameter["projectId"] = projectId; + } setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = @@ -91,11 +91,6 @@ export const SpeakerApiAxiosParamCreator = function ( ...headersFromBaseOptions, ...options.headers, }; - localVarRequestOptions.data = serializeDataIfNeeded( - speaker, - localVarRequestOptions, - configuration - ); return { url: toPathString(localVarUrlObj), @@ -292,25 +287,22 @@ export const SpeakerApiAxiosParamCreator = function ( * * @param {string} projectId * @param {string} speakerId - * @param {Speaker} speaker * @param {*} [options] Override http request option. * @throws {RequiredError} */ - updateSpeaker: async ( + removeConsent: async ( projectId: string, speakerId: string, - speaker: Speaker, options: any = {} ): Promise => { // verify required parameter 'projectId' is not null or undefined - assertParamExists("updateSpeaker", "projectId", projectId); + assertParamExists("removeConsent", "projectId", projectId); // verify required parameter 'speakerId' is not null or undefined - assertParamExists("updateSpeaker", "speakerId", speakerId); - // verify required parameter 'speaker' is not null or undefined - assertParamExists("updateSpeaker", "speaker", speaker); - const localVarPath = `/v1/projects/{projectId}/speakers/{speakerId}` - .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) - .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))); + assertParamExists("removeConsent", "speakerId", speakerId); + const localVarPath = + `/v1/projects/{projectId}/speakers/removeconsent/{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; @@ -319,15 +311,13 @@ export const SpeakerApiAxiosParamCreator = function ( } const localVarRequestOptions = { - method: "PUT", + method: "GET", ...baseOptions, ...options, }; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - localVarHeaderParameter["Content-Type"] = "application/json"; - setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; @@ -336,11 +326,6 @@ export const SpeakerApiAxiosParamCreator = function ( ...headersFromBaseOptions, ...options.headers, }; - localVarRequestOptions.data = serializeDataIfNeeded( - speaker, - localVarRequestOptions, - configuration - ); return { url: toPathString(localVarUrlObj), @@ -368,7 +353,7 @@ export const SpeakerApiAxiosParamCreator = function ( // verify required parameter 'name' is not null or undefined assertParamExists("updateSpeakerName", "name", name); const localVarPath = - `/v1/projects/{projectId}/speakers/{speakerId}/changename/{name}` + `/v1/projects/{projectId}/speakers/update/{speakerId}/{name}` .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) .replace(`{${"speakerId"}}`, encodeURIComponent(String(speakerId))) .replace(`{${"name"}}`, encodeURIComponent(String(name))); @@ -396,6 +381,85 @@ export const SpeakerApiAxiosParamCreator = function ( ...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} + */ + uploadConsentAudio: async ( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("uploadConsentAudio", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("uploadConsentAudio", "speakerId", speakerId); + // verify required parameter 'file' is not null or undefined + assertParamExists("uploadConsentAudio", "file", file); + // verify required parameter 'name' is not null or undefined + assertParamExists("uploadConsentAudio", "name", name); + // verify required parameter 'filePath' is not null or undefined + assertParamExists("uploadConsentAudio", "filePath", filePath); + const localVarPath = + `/v1/projects/{projectId}/speakers/uploadconsentaudio/{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, @@ -413,21 +477,21 @@ export const SpeakerApiFp = function (configuration?: Configuration) { return { /** * - * @param {string} projectId - * @param {Speaker} speaker + * @param {string} name + * @param {string} [projectId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ async createSpeaker( - projectId: string, - speaker: Speaker, + name: string, + projectId?: string, options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.createSpeaker( + name, projectId, - speaker, options ); return createRequestFunction( @@ -538,22 +602,19 @@ export const SpeakerApiFp = function (configuration?: Configuration) { * * @param {string} projectId * @param {string} speakerId - * @param {Speaker} speaker * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async updateSpeaker( + async removeConsent( projectId: string, speakerId: string, - speaker: Speaker, options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateSpeaker( + const localVarAxiosArgs = await localVarAxiosParamCreator.removeConsent( projectId, speakerId, - speaker, options ); return createRequestFunction( @@ -593,6 +654,42 @@ export const SpeakerApiFp = function (configuration?: Configuration) { 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 uploadConsentAudio( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.uploadConsentAudio( + projectId, + speakerId, + file, + name, + filePath, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -609,18 +706,18 @@ export const SpeakerApiFactory = function ( return { /** * - * @param {string} projectId - * @param {Speaker} speaker + * @param {string} name + * @param {string} [projectId] * @param {*} [options] Override http request option. * @throws {RequiredError} */ createSpeaker( - projectId: string, - speaker: Speaker, + name: string, + projectId?: string, options?: any ): AxiosPromise { return localVarFp - .createSpeaker(projectId, speaker, options) + .createSpeaker(name, projectId, options) .then((request) => request(axios, basePath)); }, /** @@ -687,18 +784,16 @@ export const SpeakerApiFactory = function ( * * @param {string} projectId * @param {string} speakerId - * @param {Speaker} speaker * @param {*} [options] Override http request option. * @throws {RequiredError} */ - updateSpeaker( + removeConsent( projectId: string, speakerId: string, - speaker: Speaker, options?: any ): AxiosPromise { return localVarFp - .updateSpeaker(projectId, speakerId, speaker, options) + .removeConsent(projectId, speakerId, options) .then((request) => request(axios, basePath)); }, /** @@ -719,6 +814,28 @@ export const SpeakerApiFactory = function ( .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} + */ + uploadConsentAudio( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): AxiosPromise { + return localVarFp + .uploadConsentAudio(projectId, speakerId, file, name, filePath, options) + .then((request) => request(axios, basePath)); + }, }; }; @@ -733,14 +850,14 @@ export interface SpeakerApiCreateSpeakerRequest { * @type {string} * @memberof SpeakerApiCreateSpeaker */ - readonly projectId: string; + readonly name: string; /** * - * @type {Speaker} + * @type {string} * @memberof SpeakerApiCreateSpeaker */ - readonly speaker: Speaker; + readonly projectId?: string; } /** @@ -814,31 +931,24 @@ export interface SpeakerApiGetSpeakerRequest { } /** - * Request parameters for updateSpeaker operation in SpeakerApi. + * Request parameters for removeConsent operation in SpeakerApi. * @export - * @interface SpeakerApiUpdateSpeakerRequest + * @interface SpeakerApiRemoveConsentRequest */ -export interface SpeakerApiUpdateSpeakerRequest { +export interface SpeakerApiRemoveConsentRequest { /** * * @type {string} - * @memberof SpeakerApiUpdateSpeaker + * @memberof SpeakerApiRemoveConsent */ readonly projectId: string; /** * * @type {string} - * @memberof SpeakerApiUpdateSpeaker + * @memberof SpeakerApiRemoveConsent */ readonly speakerId: string; - - /** - * - * @type {Speaker} - * @memberof SpeakerApiUpdateSpeaker - */ - readonly speaker: Speaker; } /** @@ -869,6 +979,48 @@ export interface SpeakerApiUpdateSpeakerNameRequest { readonly name: string; } +/** + * Request parameters for uploadConsentAudio operation in SpeakerApi. + * @export + * @interface SpeakerApiUploadConsentAudioRequest + */ +export interface SpeakerApiUploadConsentAudioRequest { + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentAudio + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentAudio + */ + readonly speakerId: string; + + /** + * + * @type {any} + * @memberof SpeakerApiUploadConsentAudio + */ + readonly file: any; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentAudio + */ + readonly name: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentAudio + */ + readonly filePath: string; +} + /** * SpeakerApi - object-oriented interface * @export @@ -889,8 +1041,8 @@ export class SpeakerApi extends BaseAPI { ) { return SpeakerApiFp(this.configuration) .createSpeaker( + requestParameters.name, requestParameters.projectId, - requestParameters.speaker, options ) .then((request) => request(this.axios, this.basePath)); @@ -970,20 +1122,19 @@ export class SpeakerApi extends BaseAPI { /** * - * @param {SpeakerApiUpdateSpeakerRequest} requestParameters Request parameters. + * @param {SpeakerApiRemoveConsentRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SpeakerApi */ - public updateSpeaker( - requestParameters: SpeakerApiUpdateSpeakerRequest, + public removeConsent( + requestParameters: SpeakerApiRemoveConsentRequest, options?: any ) { return SpeakerApiFp(this.configuration) - .updateSpeaker( + .removeConsent( requestParameters.projectId, requestParameters.speakerId, - requestParameters.speaker, options ) .then((request) => request(this.axios, this.basePath)); @@ -1009,4 +1160,27 @@ export class SpeakerApi extends BaseAPI { ) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {SpeakerApiUploadConsentAudioRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public uploadConsentAudio( + requestParameters: SpeakerApiUploadConsentAudioRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .uploadConsentAudio( + requestParameters.projectId, + requestParameters.speakerId, + requestParameters.file, + requestParameters.name, + requestParameters.filePath, + options + ) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 0ea96fb74a..b896c0c12b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -19,6 +19,7 @@ import { SemanticDomainTreeNode, SemanticDomainUserCount, SiteBanner, + Speaker, User, UserEdit, UserRole, @@ -101,6 +102,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); @@ -459,6 +461,69 @@ export async function getSemanticDomainTreeNodeByName( return response.data ?? undefined; } +/* SpeakerController.cs */ + +/** 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( + speakerId: string, + projectId?: string +): Promise { + projectId = projectId || LocalStorage.getProjectId(); + const params = { projectId, speakerId }; + 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 audio consent for specified speaker (in current project if no projectId given). + * Overwrites any previous consent for the speaker. + * Returns updated speaker. */ +export async function uploadConsentAudio( + speakerId: string, + audioFile: File, + projectId?: string +): Promise { + projectId = projectId || LocalStorage.getProjectId(); + const resp = await speakerApi.uploadConsentAudio( + { projectId, speakerId, ...fileUpload(audioFile) }, + { headers: { ...authHeader(), "content-type": "application/json" } } + ); + return resp.data; +} + /* StatisticsController.cs */ export async function getSemanticDomainCounts( 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 b8fbd72593..a141ee817e 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).toBeCalledTimes(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).toBeCalledTimes(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); }); }); From 9613cea36c666fc3935aa1dd19332bc0142b7615 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 06:23:04 -0500 Subject: [PATCH 03/56] Negotiate with CodeQL --- Backend/Controllers/SpeakerController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index b558a5502b..25eb1a4e5e 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -238,14 +238,14 @@ public async Task UploadConsentAudio(string projectId, string spe } // Copy file data to a local file with speakerId-dependent name - fileUpload.FilePath = FileStorage.GenerateAudioFilePathForWord(projectId, speakerId); - await using (var fs = new IO.FileStream(fileUpload.FilePath, IO.FileMode.Create)) + var path = FileStorage.GenerateAudioFilePathForWord(projectId, speakerId); + await using (var fs = new IO.FileStream(path, IO.FileMode.Create)) { await file.CopyToAsync(fs); } // Update speaker consent and return result with speaker - var fileName = IO.Path.GetFileName(fileUpload.FilePath); + var fileName = IO.Path.GetFileName(path); speaker.Consent = new() { FileName = fileName, FileType = ConsentType.Audio }; return await _speakerRepo.Update(speakerId, speaker) switch { From f26a00b613fffae51331c0a12c57ce641a527e2f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 07:20:34 -0500 Subject: [PATCH 04/56] Add Speaker types to Startup.cs --- Backend/Contexts/SpeakerContext.cs | 22 ++++++++++++++++++++++ Backend/Startup.cs | 26 +++++++++++++++----------- 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 Backend/Contexts/SpeakerContext.cs 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/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. From 239e764718ac51181931f43143af8924e11c51dd Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 07:21:00 -0500 Subject: [PATCH 05/56] Add skeleton setting for project speakers --- public/locales/en/translation.json | 14 +++ src/backend/index.ts | 7 ++ src/components/ProjectSettings/index.tsx | 12 ++ .../ProjectSettings/tests/SettingsTabTypes.ts | 7 +- .../ProjectSettings/tests/index.test.tsx | 1 + .../ProjectUsers/ProjectSpeakers.tsx | 104 ++++++++++++++++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/components/ProjectUsers/ProjectSpeakers.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 3471c9ef49..adcab48de6 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -197,6 +197,20 @@ "manageUser": "Manage User", "reverseOrder": "Reverse Order" }, + "speaker": { + "label": "Manage speakers", + "add": "Add a speaker", + "delete": "Delete this speaker", + "edit": "Edit speaker's name", + "consent": { + "play": "Listen to the audio consent for this speaker", + "record": "Record audio consent for this speaker", + "see": "Look at the image consent for this speaker", + "upload": "Upload image consent for this speaker", + "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.", diff --git a/src/backend/index.ts b/src/backend/index.ts index b896c0c12b..7112d3a060 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -463,6 +463,13 @@ export async function getSemanticDomainTreeNodeByName( /* SpeakerController.cs */ +/** Get all speakers (in current project if no projectId given). + * Returns array of speakers. */ +export async function getAllSpeakers(projectId?: string): Promise { + const params = { projectId: projectId || LocalStorage.getProjectId() }; + return (await speakerApi.getProjectSpeakers(params, defaultOptions())).data; +} + /** Creates new speaker (in current project if no projectId given). * Returns id of new speaker. */ export async function createSpeaker( diff --git a/src/components/ProjectSettings/index.tsx b/src/components/ProjectSettings/index.tsx index a0fab81c71..2453b7d203 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 ProjectSpeakers from "components/ProjectUsers/ProjectSpeakers"; 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/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx new file mode 100644 index 0000000000..b88d5161d4 --- /dev/null +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -0,0 +1,104 @@ +import { + Add, + AddPhotoAlternate, + Delete, + Edit, + FiberManualRecord, + Image, + PlayArrow, +} from "@mui/icons-material"; +import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; +import { Fragment, ReactElement, useEffect, useState } from "react"; + +import { ConsentType, Speaker } from "api/models"; +import { getAllSpeakers } from "backend"; +import { IconButtonWithTooltip } from "components/Buttons"; + +export default function ProjectSpeakers(props: { + projectId: string; +}): ReactElement { + const [projSpeakers, setProjSpeakers] = useState([]); + + useEffect(() => { + getAllSpeakers(props.projectId).then((speakers) => + setProjSpeakers(speakers.sort((a, b) => a.name.localeCompare(b.name))) + ); + }, [props.projectId]); + + return ( + + {projSpeakers.map((s) => ( + + ))} + + {}}> + } + textId="projectSettings.speaker.add" + /> + + + + ); +} + +export function SpeakerListItem(props: { speaker: Speaker }): ReactElement { + const { consent, id, name } = props.speaker; + const consentButton = !consent.fileName ? ( + + ) : consent.fileType === ConsentType.Audio ? ( + {}}> + } + textId="projectSettings.speaker.consent.play" + /> + + ) : consent.fileType === ConsentType.Image ? ( + {}}> + } + textId="projectSettings.speaker.consent.look" + /> + + ) : ( + + ); + + return ( + + + {consentButton} + {}}> + } + textId="projectSettings.speaker.consent.record" + /> + + {}}> + } + textId="projectSettings.speaker.consent.upload" + /> + + {}}> + } + textId="projectSettings.speaker.edit" + /> + + {}}> + } + textId="projectSettings.speaker.delete" + /> + + + ); +} From 32a888d88716e6a97ec7805e42e9971158fbdebb Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 09:28:05 -0500 Subject: [PATCH 06/56] Enable add/edit/delete speaker --- public/locales/en/translation.json | 1 + src/components/Dialogs/SubmitTextDialog.tsx | 112 ++++++++++++ .../ProjectUsers/ProjectSpeakers.tsx | 159 +++++++++++++++--- 3 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 src/components/Dialogs/SubmitTextDialog.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index adcab48de6..5c1070ab95 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -200,6 +200,7 @@ "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": { diff --git a/src/components/Dialogs/SubmitTextDialog.tsx b/src/components/Dialogs/SubmitTextDialog.tsx new file mode 100644 index 0000000000..f1bfbd433d --- /dev/null +++ b/src/components/Dialogs/SubmitTextDialog.tsx @@ -0,0 +1,112 @@ +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 editing text and confirm or cancel the edit + */ +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/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index b88d5161d4..31207bd800 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -8,42 +8,65 @@ import { PlayArrow, } from "@mui/icons-material"; import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; -import { Fragment, ReactElement, useEffect, useState } from "react"; +import { + Fragment, + ReactElement, + useCallback, + useEffect, + useState, +} from "react"; import { ConsentType, Speaker } from "api/models"; -import { getAllSpeakers } from "backend"; +import { + createSpeaker, + deleteSpeaker, + getAllSpeakers, + updateSpeakerName, +} from "backend"; import { IconButtonWithTooltip } from "components/Buttons"; +import { CancelConfirmDialog, EditTextDialog } from "components/Dialogs"; +import SubmitTextDialog from "components/Dialogs/SubmitTextDialog"; export default function ProjectSpeakers(props: { projectId: string; }): ReactElement { const [projSpeakers, setProjSpeakers] = useState([]); - useEffect(() => { + const getProjectSpeakers = useCallback(() => { getAllSpeakers(props.projectId).then((speakers) => setProjSpeakers(speakers.sort((a, b) => a.name.localeCompare(b.name))) ); }, [props.projectId]); + useEffect(() => { + getProjectSpeakers(); + }, [getProjectSpeakers]); + return ( {projSpeakers.map((s) => ( - + ))} - - {}}> - } - textId="projectSettings.speaker.add" - /> - - + ); } -export function SpeakerListItem(props: { speaker: Speaker }): ReactElement { +interface ProjSpeakerProps { + projectId: string; + refresh: () => void | Promise; + speaker: Speaker; +} + +function SpeakerListItem(props: ProjSpeakerProps): ReactElement { const { consent, id, name } = props.speaker; const consentButton = !consent.fileName ? ( @@ -85,20 +108,106 @@ export function SpeakerListItem(props: { speaker: Speaker }): ReactElement { textId="projectSettings.speaker.consent.upload" /> - {}}> - } - textId="projectSettings.speaker.edit" - /> - - {}}> + + + + ); +} + +function EditSpeakerNameIcon(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 DeleteSpeakerIcon(props: ProjSpeakerProps): ReactElement { + const [open, setOpen] = useState(false); + + const handleConfirm = async (): Promise => { + await deleteSpeaker(props.speaker.id, props.projectId); + await props.refresh(); + }; + + return ( + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.delete" + /> + setOpen(false)} + handleConfirm={handleConfirm} + open={open} + textId={ + props.speaker.consent.fileName + ? "projectSettings.speaker.consent.warning" + : "projectSettings.speaker.delete" + } + /> + + ); +} + +interface AddSpeakerProps { + projectId: string; + refresh: () => void | Promise; +} + +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 ( + + } - textId="projectSettings.speaker.delete" + buttonId={"project-speakers-add"} + icon={} + onClick={() => setOpen(true)} + textId="projectSettings.speaker.add" /> + setOpen(false)} + open={open} + submitText={handleSubmitText} + textFieldId={"project-speakers-add-name"} + titleId={"projectSettings.speaker.enterName"} + /> ); } From 8b07ee447b14ab32f9a1b252b94b43057905b78e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 10:33:20 -0500 Subject: [PATCH 07/56] Use mic icon --- src/components/Dialogs/index.ts | 2 ++ src/components/ProjectUsers/ProjectSpeakers.tsx | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/Dialogs/index.ts b/src/components/Dialogs/index.ts index f501a59597..943a496b1a 100644 --- a/src/components/Dialogs/index.ts +++ b/src/components/Dialogs/index.ts @@ -2,10 +2,12 @@ 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 SubmitTextDialog from "components/Dialogs/SubmitTextDialog"; export { ButtonConfirmation, CancelConfirmDialog, DeleteEditTextDialog, EditTextDialog, + SubmitTextDialog, }; diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index 31207bd800..b87ed5640b 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -3,8 +3,8 @@ import { AddPhotoAlternate, Delete, Edit, - FiberManualRecord, Image, + Mic, PlayArrow, } from "@mui/icons-material"; import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; @@ -24,8 +24,11 @@ import { updateSpeakerName, } from "backend"; import { IconButtonWithTooltip } from "components/Buttons"; -import { CancelConfirmDialog, EditTextDialog } from "components/Dialogs"; -import SubmitTextDialog from "components/Dialogs/SubmitTextDialog"; +import { + CancelConfirmDialog, + EditTextDialog, + SubmitTextDialog, +} from "components/Dialogs"; export default function ProjectSpeakers(props: { projectId: string; @@ -97,7 +100,7 @@ function SpeakerListItem(props: ProjSpeakerProps): ReactElement { {}}> } + icon={} textId="projectSettings.speaker.consent.record" /> From 344072c96dea60ebef4abc8d4f95f7e02cd5df08 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 11:22:41 -0500 Subject: [PATCH 08/56] Add consent image upload interface --- public/locales/en/translation.json | 3 +- .../UploadImage.tsx} | 18 +++++----- src/components/Dialogs/UploadImageDialog.tsx | 30 ++++++++++++++++ src/components/Dialogs/index.ts | 2 ++ .../ProjectUsers/ProjectSpeakers.tsx | 35 +++++++++++++++---- .../UserSettings/ClickableAvatar.tsx | 19 +++++----- .../UserSettings/tests/AvatarUpload.test.tsx | 16 --------- 7 files changed, 81 insertions(+), 42 deletions(-) rename src/components/{UserSettings/AvatarUpload.tsx => Dialogs/UploadImage.tsx} (76%) create mode 100644 src/components/Dialogs/UploadImageDialog.tsx delete mode 100644 src/components/UserSettings/tests/AvatarUpload.test.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 5c1070ab95..4a10b5c16e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -126,7 +126,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.", diff --git a/src/components/UserSettings/AvatarUpload.tsx b/src/components/Dialogs/UploadImage.tsx similarity index 76% rename from src/components/UserSettings/AvatarUpload.tsx rename to src/components/Dialogs/UploadImage.tsx index 55f8c562e6..92c232f67f 100644 --- a/src/components/UserSettings/AvatarUpload.tsx +++ b/src/components/Dialogs/UploadImage.tsx @@ -1,19 +1,18 @@ import { Grid, Typography } from "@mui/material"; -import React, { ReactElement, useState } from "react"; +import { FormEvent, 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 { +interface ImageUploadProps { doneCallback?: () => void; + uploadImage: (imgFile: File) => Promise; } /** - * Allows the current user to select an image and upload as their avatar + * Allows the current user to select an image and upload it */ -export default function AvatarUpload(props: AvatarUploadProps): ReactElement { +export default function ImageUpload(props: ImageUploadProps): ReactElement { const [file, setFile] = useState(); const [filename, setFilename] = useState(); const [loading, setLoading] = useState(false); @@ -27,12 +26,13 @@ export default function AvatarUpload(props: AvatarUploadProps): ReactElement { } } - async function upload(e: React.FormEvent): Promise { + async function upload(e: FormEvent): Promise { e.preventDefault(); e.stopPropagation(); if (file) { setLoading(true); - await uploadAvatar(getUserId(), file) + await props + .uploadImage(file) .then(onDone) .catch(() => setLoading(false)); } @@ -66,7 +66,7 @@ export default function AvatarUpload(props: AvatarUploadProps): ReactElement { {t("buttons.save")} diff --git a/src/components/Dialogs/UploadImageDialog.tsx b/src/components/Dialogs/UploadImageDialog.tsx new file mode 100644 index 0000000000..39b92d4d2e --- /dev/null +++ b/src/components/Dialogs/UploadImageDialog.tsx @@ -0,0 +1,30 @@ +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import UploadImage from "components/Dialogs/UploadImage"; + +interface UploadImageDialogProps { + close: () => void; + open: boolean; + titleId: string; + uploadImage: (imgFile: File) => Promise; +} + +export default function UploadImageDialog( + props: UploadImageDialogProps +): ReactElement { + const { t } = useTranslation(); + + return ( + + {t(props.titleId)} + + + + + ); +} diff --git a/src/components/Dialogs/index.ts b/src/components/Dialogs/index.ts index 943a496b1a..c8a4d65b10 100644 --- a/src/components/Dialogs/index.ts +++ b/src/components/Dialogs/index.ts @@ -3,6 +3,7 @@ import CancelConfirmDialog from "components/Dialogs/CancelConfirmDialog"; import DeleteEditTextDialog from "components/Dialogs/DeleteEditTextDialog"; import EditTextDialog from "components/Dialogs/EditTextDialog"; import SubmitTextDialog from "components/Dialogs/SubmitTextDialog"; +import UploadImageDialog from "components/Dialogs/UploadImageDialog"; export { ButtonConfirmation, @@ -10,4 +11,5 @@ export { DeleteEditTextDialog, EditTextDialog, SubmitTextDialog, + UploadImageDialog, }; diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index b87ed5640b..7dbc59aaeb 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -28,6 +28,7 @@ import { CancelConfirmDialog, EditTextDialog, SubmitTextDialog, + UploadImageDialog, } from "components/Dialogs"; export default function ProjectSpeakers(props: { @@ -104,19 +105,39 @@ function SpeakerListItem(props: ProjSpeakerProps): ReactElement { textId="projectSettings.speaker.consent.record" /> - {}}> - } - textId="projectSettings.speaker.consent.upload" - /> - + ); } +function UploadConsentImageIcon(props: ProjSpeakerProps): ReactElement { + const [open, setOpen] = useState(false); + + const handleUploadImage = async (imgFile: File): Promise => { + //TODO await (props.speaker.id, name, props.projectId); + await props.refresh(); + }; + + return ( + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.consent.upload" + /> + setOpen(false)} + open={open} + titleId="projectSettings.speaker.consent.upload" + uploadImage={handleUploadImage} + /> + + ); +} + function EditSpeakerNameIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); diff --git a/src/components/UserSettings/ClickableAvatar.tsx b/src/components/UserSettings/ClickableAvatar.tsx index 62939421e4..6ba5ae9805 100644 --- a/src/components/UserSettings/ClickableAvatar.tsx +++ b/src/components/UserSettings/ClickableAvatar.tsx @@ -1,9 +1,10 @@ import { CameraAlt, Person } from "@mui/icons-material"; -import { Avatar, Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { Avatar } from "@mui/material"; import { ReactElement, useState } from "react"; -import { getAvatar } from "backend/localStorage"; -import AvatarUpload from "components/UserSettings/AvatarUpload"; +import { uploadAvatar } from "backend"; +import { getAvatar, getUserId } from "backend/localStorage"; +import { UploadImageDialog } from "components/Dialogs"; const avatarStyle = { height: 60, width: 60 }; const avatarOverlayStyle = { @@ -48,12 +49,12 @@ export default function ClickableAvatar( - - Set user avatar - - - - + uploadAvatar(getUserId(), imgFile)} + /> ); } diff --git a/src/components/UserSettings/tests/AvatarUpload.test.tsx b/src/components/UserSettings/tests/AvatarUpload.test.tsx deleted file mode 100644 index 5bf8f4c4f9..0000000000 --- a/src/components/UserSettings/tests/AvatarUpload.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import renderer from "react-test-renderer"; - -import "tests/reactI18nextMock"; - -import AvatarUpload from "components/UserSettings/AvatarUpload"; - -let testRenderer: renderer.ReactTestRenderer; - -describe("AvatarUpload", () => { - it("renders", () => { - renderer.act(() => { - testRenderer = renderer.create(); - }); - expect(testRenderer.root.findAllByType(AvatarUpload)).toHaveLength(1); - }); -}); From 2d27bfbd99392715da73ea1f5cbc0f0aa2f04de8 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 13:22:51 -0500 Subject: [PATCH 09/56] Finish consent upload interfaces and api --- Backend/Controllers/SpeakerController.cs | 78 ++++++- src/api/api/speaker-api.ts | 202 ++++++++++++++++++ src/backend/index.ts | 25 +-- src/components/Dialogs/RecordAudioDialog.tsx | 28 +++ src/components/Dialogs/UploadImageDialog.tsx | 2 +- src/components/Dialogs/index.ts | 2 + .../ProjectUsers/ProjectSpeakers.tsx | 41 +++- .../Pronunciations/AudioRecorder.tsx | 6 +- .../Pronunciations/PronunciationsBackend.tsx | 2 +- .../Pronunciations/PronunciationsFrontend.tsx | 2 +- .../Pronunciations/RecorderIcon.tsx | 6 +- .../tests/AudioRecorder.test.tsx | 8 +- 12 files changed, 362 insertions(+), 40 deletions(-) create mode 100644 src/components/Dialogs/RecordAudioDialog.tsx diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index 25eb1a4e5e..ba3cadb875 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -141,14 +141,18 @@ public async Task RemoveConsent(string projectId, string speakerI } // Delete consent file - if (string.IsNullOrEmpty(speaker.Consent.FileName)) + var filePath = speaker.Consent.FileName; + if (string.IsNullOrEmpty(filePath)) { return StatusCode(StatusCodes.Status304NotModified, speakerId); } - var FilePath = FileStorage.GenerateAudioFilePath(projectId, speaker.Consent.FileName); - if (IO.File.Exists(FilePath)) + if (speaker.Consent.FileType == ConsentType.Audio) { - IO.File.Delete(FilePath); + filePath = FileStorage.GenerateAudioFilePath(projectId, filePath); + } + if (IO.File.Exists(filePath)) + { + IO.File.Delete(filePath); } // Update speaker and return result with id @@ -199,8 +203,8 @@ public async Task UpdateSpeakerName(string projectId, string spea /// Updated speaker [HttpPost("uploadconsentaudio/{speakerId}", Name = "UploadConsentAudio")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] - public async Task UploadConsentAudio(string projectId, string speakerId, - [FromForm] FileUpload fileUpload) + public async Task UploadConsentAudio( + string projectId, string speakerId, [FromForm] FileUpload fileUpload) { // Sanitize user input try @@ -253,5 +257,67 @@ public async Task UploadConsentAudio(string projectId, string spe _ => Ok(speaker), }; } + + /// + /// Adds an image consent from + /// locally to ~/.CombineFiles/{ProjectId}/Avatars + /// and updates the of the specified + /// + /// Updated speaker + [HttpPost("uploadconsentimage/{speakerId}", Name = "UploadConsentImage")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] + public async Task UploadConsentImage( + string projectId, string speakerId, [FromForm] FileUpload fileUpload) + { + // 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 not empty. + var file = fileUpload.File; + if (file is null) + { + return BadRequest("Null File"); + } + if (file.Length == 0) + { + return BadRequest("Empty File"); + } + + // Copy file data to a new local file + var path = FileStorage.GenerateAvatarFilePath(speakerId); + await using (var fs = new IO.FileStream(path, IO.FileMode.OpenOrCreate)) + { + await file.CopyToAsync(fs); + } + + // Update speaker consent and return result with speaker + speaker.Consent = new() { FileName = path, FileType = ConsentType.Image }; + return await _speakerRepo.Update(speakerId, speaker) switch + { + ResultOfUpdate.NotFound => NotFound(speaker), + _ => Ok(speaker), + }; + } } } diff --git a/src/api/api/speaker-api.ts b/src/api/api/speaker-api.ts index 0f6551922d..f5be435e0d 100644 --- a/src/api/api/speaker-api.ts +++ b/src/api/api/speaker-api.ts @@ -460,6 +460,85 @@ export const SpeakerApiAxiosParamCreator = function ( }; localVarRequestOptions.data = localVarFormParams; + 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} + */ + uploadConsentImage: async ( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("uploadConsentImage", "projectId", projectId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("uploadConsentImage", "speakerId", speakerId); + // verify required parameter 'file' is not null or undefined + assertParamExists("uploadConsentImage", "file", file); + // verify required parameter 'name' is not null or undefined + assertParamExists("uploadConsentImage", "name", name); + // verify required parameter 'filePath' is not null or undefined + assertParamExists("uploadConsentImage", "filePath", filePath); + const localVarPath = + `/v1/projects/{projectId}/speakers/uploadconsentimage/{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, @@ -690,6 +769,42 @@ export const SpeakerApiFp = function (configuration?: Configuration) { 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 uploadConsentImage( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.uploadConsentImage( + projectId, + speakerId, + file, + name, + filePath, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -836,6 +951,28 @@ export const SpeakerApiFactory = function ( .uploadConsentAudio(projectId, speakerId, file, name, filePath, 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} + */ + uploadConsentImage( + projectId: string, + speakerId: string, + file: any, + name: string, + filePath: string, + options?: any + ): AxiosPromise { + return localVarFp + .uploadConsentImage(projectId, speakerId, file, name, filePath, options) + .then((request) => request(axios, basePath)); + }, }; }; @@ -1021,6 +1158,48 @@ export interface SpeakerApiUploadConsentAudioRequest { readonly filePath: string; } +/** + * Request parameters for uploadConsentImage operation in SpeakerApi. + * @export + * @interface SpeakerApiUploadConsentImageRequest + */ +export interface SpeakerApiUploadConsentImageRequest { + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentImage + */ + readonly projectId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentImage + */ + readonly speakerId: string; + + /** + * + * @type {any} + * @memberof SpeakerApiUploadConsentImage + */ + readonly file: any; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentImage + */ + readonly name: string; + + /** + * + * @type {string} + * @memberof SpeakerApiUploadConsentImage + */ + readonly filePath: string; +} + /** * SpeakerApi - object-oriented interface * @export @@ -1183,4 +1362,27 @@ export class SpeakerApi extends BaseAPI { ) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {SpeakerApiUploadConsentImageRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public uploadConsentImage( + requestParameters: SpeakerApiUploadConsentImageRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .uploadConsentImage( + requestParameters.projectId, + requestParameters.speakerId, + requestParameters.file, + requestParameters.name, + requestParameters.filePath, + options + ) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 7112d3a060..bcb3bd1d40 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -8,6 +8,7 @@ import { BASE_PATH } from "api/base"; import { BannerType, ChartRootData, + ConsentType, EmailInviteStatus, MergeUndoIds, MergeWords, @@ -515,20 +516,20 @@ export async function updateSpeakerName( return (await speakerApi.updateSpeakerName(params, defaultOptions())).data; } -/** Uploads audio consent for specified speaker (in current project if no projectId given). - * Overwrites any previous consent for the speaker. +/** Uploads consent for specified speaker; overwrites previous consent. * Returns updated speaker. */ -export async function uploadConsentAudio( - speakerId: string, - audioFile: File, - projectId?: string +export async function uploadConsent( + speaker: Speaker, + file: File ): Promise { - projectId = projectId || LocalStorage.getProjectId(); - const resp = await speakerApi.uploadConsentAudio( - { projectId, speakerId, ...fileUpload(audioFile) }, - { headers: { ...authHeader(), "content-type": "application/json" } } - ); - return resp.data; + const { consent, id, projectId } = speaker; + const params = { projectId, speakerId: id, ...fileUpload(file) }; + const headers = { ...authHeader(), "content-type": "application/json" }; + const response = + consent.fileType === ConsentType.Audio + ? await speakerApi.uploadConsentAudio(params, { headers }) + : await speakerApi.uploadConsentImage(params, { headers }); + return response.data; } /* StatisticsController.cs */ diff --git a/src/components/Dialogs/RecordAudioDialog.tsx b/src/components/Dialogs/RecordAudioDialog.tsx new file mode 100644 index 0000000000..c9bbcdcf24 --- /dev/null +++ b/src/components/Dialogs/RecordAudioDialog.tsx @@ -0,0 +1,28 @@ +import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import AudioRecorder from "components/Pronunciations/AudioRecorder"; + +interface RecordAudioDialogProps { + audioId: string; + close: () => void; + open: boolean; + titleId: string; + uploadAudio: (audioFile: File) => Promise; +} + +export default function RecordAudioDialog( + props: RecordAudioDialogProps +): ReactElement { + const { t } = useTranslation(); + + return ( + + {t(props.titleId)} + + + + + ); +} diff --git a/src/components/Dialogs/UploadImageDialog.tsx b/src/components/Dialogs/UploadImageDialog.tsx index 39b92d4d2e..226402b45d 100644 --- a/src/components/Dialogs/UploadImageDialog.tsx +++ b/src/components/Dialogs/UploadImageDialog.tsx @@ -8,7 +8,7 @@ interface UploadImageDialogProps { close: () => void; open: boolean; titleId: string; - uploadImage: (imgFile: File) => Promise; + uploadImage: (imageFile: File) => Promise; } export default function UploadImageDialog( diff --git a/src/components/Dialogs/index.ts b/src/components/Dialogs/index.ts index c8a4d65b10..92ca1eb75f 100644 --- a/src/components/Dialogs/index.ts +++ b/src/components/Dialogs/index.ts @@ -2,6 +2,7 @@ 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"; @@ -10,6 +11,7 @@ export { CancelConfirmDialog, DeleteEditTextDialog, EditTextDialog, + RecordAudioDialog, SubmitTextDialog, UploadImageDialog, }; diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index 7dbc59aaeb..b47ce7a83e 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -22,11 +22,13 @@ import { deleteSpeaker, getAllSpeakers, updateSpeakerName, + uploadConsent, } from "backend"; import { IconButtonWithTooltip } from "components/Buttons"; import { CancelConfirmDialog, EditTextDialog, + RecordAudioDialog, SubmitTextDialog, UploadImageDialog, } from "components/Dialogs"; @@ -98,13 +100,7 @@ function SpeakerListItem(props: ProjSpeakerProps): ReactElement { {consentButton} - {}}> - } - textId="projectSettings.speaker.consent.record" - /> - + @@ -112,11 +108,38 @@ function SpeakerListItem(props: ProjSpeakerProps): ReactElement { ); } +function RecordConsentAudioIcon(props: ProjSpeakerProps): ReactElement { + const [open, setOpen] = useState(false); + + const handleUploadAudio = async (audioFile: File): Promise => { + await uploadConsent(props.speaker, audioFile); + await props.refresh(); + }; + + return ( + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.consent.record" + /> + setOpen(false)} + open={open} + titleId="projectSettings.speaker.consent.record" + uploadAudio={handleUploadAudio} + /> + + ); +} + function UploadConsentImageIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); - const handleUploadImage = async (imgFile: File): Promise => { - //TODO await (props.speaker.id, name, props.projectId); + const handleUploadImage = async (imageFile: File): Promise => { + await uploadConsent(props.speaker, imageFile); await props.refresh(); }; diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index eff2e2edb6..a36850b830 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -8,7 +8,7 @@ import RecorderIcon from "components/Pronunciations/RecorderIcon"; import { getFileNameForWord } from "components/Pronunciations/utilities"; interface RecorderProps { - wordId: string; + id: string; uploadAudio: (audioFile: File) => void; onClick?: () => void; } @@ -30,7 +30,7 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { toast.error(t("pronunciations.noMicAccess")); return; } - const fileName = getFileNameForWord(props.wordId); + const fileName = getFileNameForWord(props.id); const options: FilePropertyBag = { lastModified: Date.now(), type: Recorder.blobType, @@ -40,7 +40,7 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { return ( diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index 0e32782d78..0359e6a97c 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -38,7 +38,7 @@ export function PronunciationsBackend( return ( <> {!props.playerOnly && !!props.uploadAudio && ( - + )} {audioButtons} diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index 1e2cd8542f..ef4076e59f 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -30,7 +30,7 @@ export default function PronunciationsFrontend( 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..b663837ace 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -39,7 +39,7 @@ beforeAll(() => { - + @@ -57,9 +57,9 @@ describe("Pronunciations", () => { @@ -82,7 +82,7 @@ describe("Pronunciations", () => { - + @@ -100,7 +100,7 @@ describe("Pronunciations", () => { - + From 4d678cd3a362eb6ef96facebc1f98c8b4eebb905 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 17 Nov 2023 13:36:26 -0500 Subject: [PATCH 10/56] Fix tests --- .../DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx | 1 - .../DataEntry/DataEntryTable/tests/RecentEntry.test.tsx | 7 ------- .../DataEntry/DataEntryTable/tests/index.test.tsx | 1 - src/components/Pronunciations/tests/AudioRecorder.test.tsx | 2 -- .../Pronunciations/tests/PronunciationsBackend.test.tsx | 6 ------ .../Pronunciations/tests/PronunciationsFrontend.test.tsx | 6 ------ .../CellComponents/tests/PronunciationsCell.test.tsx | 6 ------ .../ReviewEntriesComponent/tests/index.test.tsx | 2 -- src/setupTests.js | 6 ++++++ 9 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx index 54d410dc0e..d46ad479cd 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 } }); diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index 72220ee85f..cdfe4ada23 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -27,13 +27,6 @@ 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 mockVern = "Vernacular"; const mockGloss = "Gloss"; 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/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index b663837ace..f01d3872e9 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -17,8 +17,6 @@ import { import { StoreState } from "types"; import theme, { themeColors } from "types/theme"; -jest.mock("components/Pronunciations/Recorder"); - let testRenderer: ReactTestRenderer; const createMockStore = configureMockStore(); diff --git a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx index 9e5f5b4ef4..22e64fcaab 100644 --- a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -11,12 +11,6 @@ import PronunciationsBackend from "components/Pronunciations/PronunciationsBacke 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"); - // Test variables let testRenderer: ReactTestRenderer; const mockAudio = ["a.wav", "b.wav"]; diff --git a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx index b94b0da90d..3ec5e70e8a 100644 --- a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx @@ -11,12 +11,6 @@ import PronunciationsFrontend from "components/Pronunciations/PronunciationsFron 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"); - // Test variables let testRenderer: renderer.ReactTestRenderer; const mockStore = configureMockStore()({ pronunciationsState }); diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx index 1fe1b6abc8..3a2f4a254e 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/tests/PronunciationsCell.test.tsx @@ -11,12 +11,6 @@ import { defaultState as pronunciationsState } from "components/Pronunciations/R import PronunciationsCell from "goals/ReviewEntries/ReviewEntriesComponent/CellComponents/PronunciationsCell"; import theme from "types/theme"; -// Mock the audio components -jest - .spyOn(window.HTMLMediaElement.prototype, "pause") - .mockImplementation(() => {}); -jest.mock("components/Pronunciations/Recorder"); - // Mock the store interactions jest.mock( "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions", diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx index 298cf1ae63..47bccb1e7c 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/tests/index.test.tsx @@ -39,8 +39,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("types/hooks", () => ({ useAppDispatch: () => jest.fn(), 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"); From e581134c70664bc451f66308dea2047027402e2b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Nov 2023 12:10:38 -0500 Subject: [PATCH 11/56] Enable hearing/seeing speaker consent --- Backend/Controllers/SpeakerController.cs | 25 +++- public/locales/en/translation.json | 2 +- src/api/api/speaker-api.ts | 133 ++++++++++++++++++ src/backend/index.ts | 26 +++- src/components/Buttons/CloseButton.tsx | 25 ++++ .../Buttons/DeleteButtonWithDialog.tsx | 45 ++++++ src/components/Buttons/FlagButton.tsx | 2 +- .../Buttons/IconButtonWithTooltip.tsx | 4 +- src/components/Buttons/PartOfSpeechButton.tsx | 2 +- src/components/Buttons/index.ts | 4 + .../DataEntryTable/NewEntry/SenseDialog.tsx | 10 +- .../DataEntryTable/NewEntry/VernDialog.tsx | 10 +- .../Dialogs/DeleteEditTextDialog.tsx | 2 +- src/components/Dialogs/ViewImageDialog.tsx | 59 ++++++++ src/components/Dialogs/index.ts | 2 + .../ProjectSettings/ProjectLanguages.tsx | 8 +- .../ProjectUsers/ProjectSpeakers.tsx | 117 ++++++++------- src/components/Pronunciations/AudioPlayer.tsx | 4 +- .../MergeDupsStep/MergeDragDrop/DropWord.tsx | 6 +- .../MergeDupsStep/SenseCardContent.tsx | 6 +- .../CellComponents/DeleteCell.tsx | 51 ++----- .../CellComponents/SenseCell.tsx | 30 ++-- 22 files changed, 425 insertions(+), 148 deletions(-) create mode 100644 src/components/Buttons/CloseButton.tsx create mode 100644 src/components/Buttons/DeleteButtonWithDialog.tsx create mode 100644 src/components/Dialogs/ViewImageDialog.tsx diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index ba3cadb875..af297234ca 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -293,7 +293,7 @@ public async Task UploadConsentImage( return NotFound(speakerId); } - // Ensure file is not empty. + // Ensure file is valid var file = fileUpload.File; if (file is null) { @@ -319,5 +319,28 @@ public async Task UploadConsentImage( _ => Ok(speaker), }; } + + /// Get speaker's consent + /// Stream of local image file + [HttpGet("downloadconsentimage/{speakerId}", Name = "DownloadConsentImage")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileContentResult))] + public IActionResult DownloadConsentImage(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(); + // } + + // Ensure file exists + var path = FileStorage.GenerateAvatarFilePath(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/public/locales/en/translation.json b/public/locales/en/translation.json index 4a10b5c16e..165a185621 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -207,7 +207,7 @@ "consent": { "play": "Listen to the audio consent for this speaker", "record": "Record audio consent for this speaker", - "see": "Look at the image consent for this speaker", + "look": "Look at the image consent for this speaker", "upload": "Upload image consent for this speaker", "remove": "Remove this speaker's consent", "warning": "Warning: the speaker's current consent will be deleted--this cannot be undone." diff --git a/src/api/api/speaker-api.ts b/src/api/api/speaker-api.ts index f5be435e0d..084376a200 100644 --- a/src/api/api/speaker-api.ts +++ b/src/api/api/speaker-api.ts @@ -190,6 +190,55 @@ export const SpeakerApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {string} speakerId + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadConsentImage: async ( + speakerId: string, + projectId: string, + options: any = {} + ): Promise => { + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("downloadConsentImage", "speakerId", speakerId); + // verify required parameter 'projectId' is not null or undefined + assertParamExists("downloadConsentImage", "projectId", projectId); + const localVarPath = + `/v1/projects/{projectId}/speakers/downloadconsentimage/{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 @@ -630,6 +679,33 @@ export const SpeakerApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} speakerId + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadConsentImage( + speakerId: string, + projectId: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.downloadConsentImage( + speakerId, + projectId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} projectId @@ -865,6 +941,22 @@ export const SpeakerApiFactory = function ( .deleteSpeaker(projectId, speakerId, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} speakerId + * @param {string} projectId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadConsentImage( + speakerId: string, + projectId: string, + options?: any + ): AxiosPromise { + return localVarFp + .downloadConsentImage(speakerId, projectId, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} projectId @@ -1032,6 +1124,27 @@ export interface SpeakerApiDeleteSpeakerRequest { readonly speakerId: string; } +/** + * Request parameters for downloadConsentImage operation in SpeakerApi. + * @export + * @interface SpeakerApiDownloadConsentImageRequest + */ +export interface SpeakerApiDownloadConsentImageRequest { + /** + * + * @type {string} + * @memberof SpeakerApiDownloadConsentImage + */ + readonly speakerId: string; + + /** + * + * @type {string} + * @memberof SpeakerApiDownloadConsentImage + */ + readonly projectId: string; +} + /** * Request parameters for getProjectSpeakers operation in SpeakerApi. * @export @@ -1263,6 +1376,26 @@ export class SpeakerApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {SpeakerApiDownloadConsentImageRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SpeakerApi + */ + public downloadConsentImage( + requestParameters: SpeakerApiDownloadConsentImageRequest, + options?: any + ) { + return SpeakerApiFp(this.configuration) + .downloadConsentImage( + requestParameters.speakerId, + requestParameters.projectId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {SpeakerApiGetProjectSpeakersRequest} requestParameters Request parameters. diff --git a/src/backend/index.ts b/src/backend/index.ts index bcb3bd1d40..95bc1de3d7 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -144,8 +144,9 @@ export async function deleteAudio( } // Use of the returned url acts as an HttpGet. -export function getAudioUrl(wordId: string, fileName: string): string { - return `${apiBaseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; +export function getAudioUrl(id: string, fileName: string): string { + const projId = LocalStorage.getProjectId(); + return `${apiBaseURL}/projects/${projId}/words/${id}/audio/download/${fileName}`; } /* AvatarController.cs */ @@ -520,18 +521,33 @@ export async function updateSpeakerName( * Returns updated speaker. */ export async function uploadConsent( speaker: Speaker, - file: File + file: File, + fileType: ConsentType ): Promise { - const { consent, id, projectId } = speaker; + const { id, projectId } = speaker; const params = { projectId, speakerId: id, ...fileUpload(file) }; const headers = { ...authHeader(), "content-type": "application/json" }; const response = - consent.fileType === ConsentType.Audio + fileType === ConsentType.Audio ? await speakerApi.uploadConsentAudio(params, { headers }) : await speakerApi.uploadConsentImage(params, { headers }); return response.data; } +/** 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.downloadConsentImage(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/Buttons/CloseButton.tsx b/src/components/Buttons/CloseButton.tsx new file mode 100644 index 0000000000..073d95552c --- /dev/null +++ b/src/components/Buttons/CloseButton.tsx @@ -0,0 +1,25 @@ +import { Close } from "@mui/icons-material"; +import { IconButton } from "@mui/material"; +import { CSSProperties, ReactElement } from "react"; + +interface CloseButtonProps { + close: () => void; +} + +export default function CloseButton(props: CloseButtonProps): ReactElement { + const closeButtonStyle: CSSProperties = { + position: "absolute", + top: 0, + ...(document.body.dir === "rtl" ? { left: 0 } : { right: 0 }), + }; + + return ( + + + + ); +} diff --git a/src/components/Buttons/DeleteButtonWithDialog.tsx b/src/components/Buttons/DeleteButtonWithDialog.tsx new file mode 100644 index 0000000000..472e1c1bb4 --- /dev/null +++ b/src/components/Buttons/DeleteButtonWithDialog.tsx @@ -0,0 +1,45 @@ +import { Delete } from "@mui/icons-material"; +import { ReactElement, useState } from "react"; + +import { IconButtonWithTooltip } from "components/Buttons"; +import { CancelConfirmDialog } from "components/Dialogs"; + +interface DeleteButtonWithDialogProps { + buttonId: string; + buttonIdCancel?: string; + buttonIdConfirm?: string; + delete: () => void | Promise; + disabled?: boolean; + textId: string; + tooltipTextId?: string; +} + +export default function DeleteButtonWithDialog( + props: DeleteButtonWithDialogProps +): ReactElement { + const [open, setOpen] = useState(false); + + const handleConfirm = async (): Promise => { + await props.delete(); + setOpen(false); + }; + + return ( + <> + } + onClick={props.disabled ? undefined : () => setOpen(true)} + textId={props.tooltipTextId || props.textId} + /> + setOpen(false)} + handleConfirm={handleConfirm} + open={open} + textId={props.textId} + /> + + ); +} diff --git a/src/components/Buttons/FlagButton.tsx b/src/components/Buttons/FlagButton.tsx index 7d02f6dcef..eb59105b37 100644 --- a/src/components/Buttons/FlagButton.tsx +++ b/src/components/Buttons/FlagButton.tsx @@ -52,7 +52,7 @@ export default function FlagButton(props: FlagButtonProps): ReactElement { } text={text} textId={active ? "flags.edit" : "flags.add"} - small + size="small" onClick={ props.updateFlag ? () => setOpen(true) : active ? () => {} : undefined } diff --git a/src/components/Buttons/IconButtonWithTooltip.tsx b/src/components/Buttons/IconButtonWithTooltip.tsx index b717e8f467..90bf775e64 100644 --- a/src/components/Buttons/IconButtonWithTooltip.tsx +++ b/src/components/Buttons/IconButtonWithTooltip.tsx @@ -6,7 +6,7 @@ interface IconButtonWithTooltipProps { icon: ReactElement; text?: ReactNode; textId?: string; - small?: boolean; + size?: "large" | "medium" | "small"; onClick?: () => void; buttonId: string; side?: "bottom" | "left" | "right" | "top"; @@ -25,7 +25,7 @@ export default function IconButtonWithTooltip( diff --git a/src/components/Buttons/PartOfSpeechButton.tsx b/src/components/Buttons/PartOfSpeechButton.tsx index 05db4aa1fb..b7eac606c9 100644 --- a/src/components/Buttons/PartOfSpeechButton.tsx +++ b/src/components/Buttons/PartOfSpeechButton.tsx @@ -38,7 +38,7 @@ export default function PartOfSpeech(props: PartOfSpeechProps): ReactElement { icon={} onClick={props.onClick} side="top" - small + size="small" text={hoverText} /> ); diff --git a/src/components/Buttons/index.ts b/src/components/Buttons/index.ts index a5135428ee..6608b71fb1 100644 --- a/src/components/Buttons/index.ts +++ b/src/components/Buttons/index.ts @@ -1,3 +1,5 @@ +import CloseButton from "components/Buttons/CloseButton"; +import DeleteButtonWithDialog from "components/Buttons/DeleteButtonWithDialog"; import FileInputButton from "components/Buttons/FileInputButton"; import FlagButton from "components/Buttons/FlagButton"; import IconButtonWithTooltip from "components/Buttons/IconButtonWithTooltip"; @@ -6,6 +8,8 @@ import LoadingDoneButton from "components/Buttons/LoadingDoneButton"; import PartOfSpeechButton from "components/Buttons/PartOfSpeechButton"; export { + CloseButton, + DeleteButtonWithDialog, FileInputButton, FlagButton, IconButtonWithTooltip, diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx index 14df7766a1..946b933237 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/SenseDialog.tsx @@ -1,9 +1,7 @@ -import { Close } from "@mui/icons-material"; import { Dialog, DialogContent, Grid, - IconButton, MenuList, Typography, } from "@mui/material"; @@ -11,6 +9,7 @@ import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { GramCatGroup, Sense, Word } from "api/models"; +import { CloseButton } from "components/Buttons"; import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; import { DomainCell, @@ -107,12 +106,7 @@ export function SenseList(props: SenseListProps): ReactElement { return ( <> {/* Cancel button */} - props.closeDialog()} - style={{ position: "absolute", right: 0, top: 0 }} - > - - + {/* Header */} {t("addWords.selectSense")} {/* Sense options */} diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx index b489398dff..f453e27e54 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/VernDialog.tsx @@ -1,9 +1,7 @@ -import { Close } from "@mui/icons-material"; import { Dialog, DialogContent, Grid, - IconButton, MenuList, Typography, } from "@mui/material"; @@ -11,6 +9,7 @@ import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { GramCatGroup, Word } from "api/models"; +import { CloseButton } from "components/Buttons"; import StyledMenuItem from "components/DataEntry/DataEntryTable/NewEntry/StyledMenuItem"; import { DomainCell, @@ -108,12 +107,7 @@ export function VernList(props: VernListProps): ReactElement { return ( <> {/* Cancel button */} - props.closeDialog()} - style={{ position: "absolute", right: 0, top: 0 }} - > - - + {/* Header */} {t("addWords.selectEntry")} {/* Entry options */} diff --git a/src/components/Dialogs/DeleteEditTextDialog.tsx b/src/components/Dialogs/DeleteEditTextDialog.tsx index afbc9c8a87..ab27fe933c 100644 --- a/src/components/Dialogs/DeleteEditTextDialog.tsx +++ b/src/components/Dialogs/DeleteEditTextDialog.tsx @@ -97,7 +97,7 @@ export default function DeleteEditTextDialog( diff --git a/src/components/Dialogs/ViewImageDialog.tsx b/src/components/Dialogs/ViewImageDialog.tsx new file mode 100644 index 0000000000..a43e618bee --- /dev/null +++ b/src/components/Dialogs/ViewImageDialog.tsx @@ -0,0 +1,59 @@ +import { Dialog, DialogContent, DialogTitle, Grid } from "@mui/material"; +import { ReactElement, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { CloseButton, DeleteButtonWithDialog } from "components/Buttons"; +import { CancelConfirmDialog } from "components/Dialogs"; + +interface ViewImageDialogProps { + close: () => void; + deleteButtonId?: string; + deleteImage?: () => void | Promise; + deleteTextId?: string; + imgSrc: string; + open: boolean; + titleId: string; +} + +export default function ViewImageDialog( + props: ViewImageDialogProps +): ReactElement { + const [dialogOpen, setDialogOpen] = useState(false); + + const { t } = useTranslation(); + + const handleDelete = async (): Promise => { + setDialogOpen(false); + if (props.deleteImage) { + await props.deleteImage(); + } + props.close(); + }; + + return ( + + + {t(props.titleId)} + + + + + + + setDialogOpen(false)} + handleConfirm={handleDelete} + /> + setDialogOpen(true)} + textId={props.deleteTextId ?? ""} + /> + + + + + ); +} diff --git a/src/components/Dialogs/index.ts b/src/components/Dialogs/index.ts index 92ca1eb75f..fa2d83daf3 100644 --- a/src/components/Dialogs/index.ts +++ b/src/components/Dialogs/index.ts @@ -5,6 +5,7 @@ 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, @@ -14,4 +15,5 @@ export { RecordAudioDialog, SubmitTextDialog, UploadImageDialog, + ViewImageDialog, }; diff --git a/src/components/ProjectSettings/ProjectLanguages.tsx b/src/components/ProjectSettings/ProjectLanguages.tsx index 94466860b2..db6da1a401 100644 --- a/src/components/ProjectSettings/ProjectLanguages.tsx +++ b/src/components/ProjectSettings/ProjectLanguages.tsx @@ -115,14 +115,14 @@ export default function ProjectLanguages( } textId="projectSettings.language.makeDefaultAnalysisLanguage" - small + size="small" onClick={() => setNewAnalysisDefault(index)} buttonId={`analysis-language-${index}-bump`} /> } textId="projectSettings.language.deleteAnalysisLanguage" - small + size="small" onClick={() => deleteAnalysisWritingSystem(index)} buttonId={`analysis-language-${index}-delete`} /> @@ -282,8 +282,8 @@ export default function ProjectLanguages( ) : ( } - textId={"projectSettings.language.changeName"} - small + size="small" + textId="projectSettings.language.changeName" onClick={() => setChangeVernName(true)} buttonId={editVernacularNameButtonId} /> diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index b47ce7a83e..ef4dc05d55 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -1,12 +1,4 @@ -import { - Add, - AddPhotoAlternate, - Delete, - Edit, - Image, - Mic, - PlayArrow, -} from "@mui/icons-material"; +import { Add, AddPhotoAlternate, Edit, Image, Mic } from "@mui/icons-material"; import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; import { Fragment, @@ -21,17 +13,24 @@ import { createSpeaker, deleteSpeaker, getAllSpeakers, + getAudioUrl, + getConsentImageSrc, + removeConsent, updateSpeakerName, uploadConsent, } from "backend"; -import { IconButtonWithTooltip } from "components/Buttons"; import { - CancelConfirmDialog, + DeleteButtonWithDialog, + IconButtonWithTooltip, +} from "components/Buttons"; +import { EditTextDialog, RecordAudioDialog, SubmitTextDialog, UploadImageDialog, + ViewImageDialog, } from "components/Dialogs"; +import AudioPlayer from "components/Pronunciations/AudioPlayer"; export default function ProjectSpeakers(props: { projectId: string; @@ -73,25 +72,20 @@ interface ProjSpeakerProps { } function SpeakerListItem(props: ProjSpeakerProps): ReactElement { - const { consent, id, name } = props.speaker; + const { consent, id, name, projectId } = props.speaker; const consentButton = !consent.fileName ? ( ) : consent.fileType === ConsentType.Audio ? ( - {}}> - } - textId="projectSettings.speaker.consent.play" + + removeConsent(id, projectId)} + fileName={consent.fileName} + pronunciationUrl={getAudioUrl(id, consent.fileName)} + warningTextId="projectSettings.speaker.consent.warning" /> ) : consent.fileType === ConsentType.Image ? ( - {}}> - } - textId="projectSettings.speaker.consent.look" - /> - + ) : ( ); @@ -108,11 +102,44 @@ function SpeakerListItem(props: ProjSpeakerProps): ReactElement { ); } +function ViewConsentImageIcon(props: ProjSpeakerProps): ReactElement { + const [imgSrc, setImgSrc] = useState(""); + const [open, setOpen] = useState(false); + + useEffect(() => { + getConsentImageSrc(props.speaker).then(setImgSrc); + }, [props.speaker]); + + const handleDeleteImage = async (): Promise => { + await removeConsent(props.speaker.id, props.speaker.projectId); + await props.refresh(); + }; + + return ( + + } + onClick={() => setOpen(true)} + textId="projectSettings.speaker.consent.look" + /> + setOpen(false)} + imgSrc={imgSrc} + open={open} + titleId="projectSettings.speaker.consent.look" + deleteImage={handleDeleteImage} + deleteTextId="projectSettings.speaker.consent.remove" + /> + + ); +} + function RecordConsentAudioIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); const handleUploadAudio = async (audioFile: File): Promise => { - await uploadConsent(props.speaker, audioFile); + await uploadConsent(props.speaker, audioFile, ConsentType.Audio); await props.refresh(); }; @@ -139,7 +166,7 @@ function UploadConsentImageIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); const handleUploadImage = async (imageFile: File): Promise => { - await uploadConsent(props.speaker, imageFile); + await uploadConsent(props.speaker, imageFile, ConsentType.Image); await props.refresh(); }; @@ -178,13 +205,13 @@ function EditSpeakerNameIcon(props: ProjSpeakerProps): ReactElement { textId="projectSettings.speaker.edit" /> setOpen(false)} open={open} text={props.speaker.name} - textFieldId={"project-speakers-edit-name"} - titleId={"projectSettings.speaker.edit"} + textFieldId="project-speakers-edit-name" + titleId="projectSettings.speaker.edit" updateText={handleUpdateText} /> @@ -192,32 +219,24 @@ function EditSpeakerNameIcon(props: ProjSpeakerProps): ReactElement { } function DeleteSpeakerIcon(props: ProjSpeakerProps): ReactElement { - const [open, setOpen] = useState(false); - - const handleConfirm = async (): Promise => { + const handleDelete = async (): Promise => { await deleteSpeaker(props.speaker.id, props.projectId); await props.refresh(); }; return ( - } - onClick={() => setOpen(true)} - textId="projectSettings.speaker.delete" - /> - setOpen(false)} - handleConfirm={handleConfirm} - open={open} + buttonIdCancel="project-speakers-delete-cancel" + buttonIdConfirm="project-speakers-delete-confirm" + delete={handleDelete} textId={ props.speaker.consent.fileName ? "projectSettings.speaker.consent.warning" : "projectSettings.speaker.delete" } + tooltipTextId="projectSettings.speaker.delete" /> ); @@ -240,20 +259,20 @@ function AddSpeakerListItem(props: AddSpeakerProps): ReactElement { } onClick={() => setOpen(true)} textId="projectSettings.speaker.add" /> setOpen(false)} open={open} submitText={handleSubmitText} - textFieldId={"project-speakers-add-name"} - titleId={"projectSettings.speaker.enterName"} + textFieldId="project-speakers-add-name" + titleId="projectSettings.speaker.enterName" /> ); diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index a2a0dcbc99..d0f1d548ca 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -22,9 +22,9 @@ import { themeColors } from "types/theme"; interface PlayerProps { deleteAudio: (fileName: string) => void; fileName: string; - isPlaying?: boolean; onClick?: () => void; pronunciationUrl: string; + warningTextId?: string; } const iconStyle: CSSProperties = { color: themeColors.success }; @@ -141,7 +141,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { setDeleteConf(false)} onConfirm={() => props.deleteAudio(props.fileName)} diff --git a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx index 07aeddae21..ece2875cdf 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/MergeDragDrop/DropWord.tsx @@ -90,9 +90,9 @@ export default function DropWord(props: DropWordProps): ReactElement { {protectedWithOneChild && ( } - textId={"mergeDups.helpText.protectedWord"} - side={"top"} - small + side="top" + size="small" + textId="mergeDups.helpText.protectedWord" buttonId={`word-${props.wordId}-protected`} /> )} diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx index adc68190c1..9c08ec5f91 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SenseCardContent.tsx @@ -154,9 +154,9 @@ export default function SenseCardContent( {protectedWarning && ( } - textId={"mergeDups.helpText.protectedSense"} - side={"top"} - small + side="top" + size="small" + textId="mergeDups.helpText.protectedSense" buttonId={`sense-${props.senses[0].guid}-protected`} /> )} diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DeleteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DeleteCell.tsx index b69f7e4320..96d66fc4ce 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DeleteCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/DeleteCell.tsx @@ -1,10 +1,7 @@ -import { Delete } from "@mui/icons-material"; -import { IconButton, Tooltip } from "@mui/material"; -import React, { ReactElement, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { ReactElement } from "react"; import { deleteFrontierWord as deleteFromBackend } from "backend"; -import { CancelConfirmDialog } from "components/Dialogs"; +import { DeleteButtonWithDialog } from "components/Buttons"; import { updateAllWords } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions"; import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; import { StoreState } from "types"; @@ -15,12 +12,10 @@ interface DeleteCellProps { } export default function DeleteCell(props: DeleteCellProps): ReactElement { - const [dialogOpen, setDialogOpen] = useState(false); const words = useAppSelector( (state: StoreState) => state.reviewEntriesState.words ); const dispatch = useAppDispatch(); - const { t } = useTranslation(); const word = props.rowData; const disabled = word.protected || !!word.senses.find((s) => s.protected); @@ -29,41 +24,17 @@ export default function DeleteCell(props: DeleteCellProps): ReactElement { await deleteFromBackend(word.id); const updatedWords = words.filter((w) => w.id !== word.id); dispatch(updateAllWords(updatedWords)); - handleClose(); - } - - function handleOpen(): void { - setDialogOpen(true); - } - function handleClose(): void { - setDialogOpen(false); } return ( - - - - - - - - - - + ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell.tsx b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell.tsx index 135dd68ebd..a3094a21c7 100644 --- a/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesComponent/CellComponents/SenseCell.tsx @@ -1,8 +1,8 @@ import { Add, Delete, RestoreFromTrash } from "@mui/icons-material"; -import { Chip, IconButton, Tooltip } from "@mui/material"; +import { Chip } from "@mui/material"; import { ReactElement } from "react"; -import { useTranslation } from "react-i18next"; +import { IconButtonWithTooltip } from "components/Buttons"; import { FieldParameterStandard } from "goals/ReviewEntries/ReviewEntriesComponent/CellColumns"; import AlignedList from "goals/ReviewEntries/ReviewEntriesComponent/CellComponents/AlignedList"; import { ReviewEntriesSense } from "goals/ReviewEntries/ReviewEntriesComponent/ReviewEntriesTypes"; @@ -12,8 +12,6 @@ interface SenseCellProps extends FieldParameterStandard { } export default function SenseCell(props: SenseCellProps): ReactElement { - const { t } = useTranslation(); - function addSense(): ReactElement { const senses = [...props.rowData.senses, new ReviewEntriesSense()]; return ( @@ -32,22 +30,16 @@ export default function SenseCell(props: SenseCellProps): ReactElement { ( - : } key={sense.guid} - > - - props.delete!(sense.guid)} - id={`sense-${sense.guid}-delete`} - disabled={sense.protected} - > - {sense.deleted ? : } - - - + onClick={ + sense.protected ? undefined : () => props.delete!(sense.guid) + } + size="small" + textId={sense.protected ? "reviewEntries.deleteDisabled" : undefined} + /> ))} bottomCell={addSense()} /> From 76046f1609dba94428dc29d6429ad5676c6f3008 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Nov 2023 14:20:34 -0500 Subject: [PATCH 12/56] Adjust play button size --- src/components/ProjectUsers/ProjectSpeakers.tsx | 1 + src/components/Pronunciations/AudioPlayer.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index ef4dc05d55..3490d69e18 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -81,6 +81,7 @@ function SpeakerListItem(props: ProjSpeakerProps): ReactElement { deleteAudio={() => removeConsent(id, projectId)} fileName={consent.fileName} pronunciationUrl={getAudioUrl(id, consent.fileName)} + size={"small"} warningTextId="projectSettings.speaker.consent.warning" /> diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index d0f1d548ca..7e52a549af 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -24,6 +24,7 @@ interface PlayerProps { fileName: string; onClick?: () => void; pronunciationUrl: string; + size?: "large" | "medium" | "small"; warningTextId?: string; } @@ -106,7 +107,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { onTouchEnd={enableContextMenu} aria-label="play" id={`audio-${props.fileName}`} - size="large" + size={props.size || "large"} > {isPlaying ? : } From 26c5129d553a84b778cdb1375c977947cbab95ae Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Nov 2023 17:48:55 -0500 Subject: [PATCH 13/56] Fix up consent buttons and api functions --- Backend.Tests/Models/SpeakerTests.cs | 51 +-- Backend/Controllers/SpeakerController.cs | 107 ++---- Backend/Helper/FileStorage.cs | 37 +-- Backend/Models/Speaker.cs | 43 +-- src/api/.openapi-generator/FILES | 1 - src/api/api/speaker-api.ts | 308 +++--------------- src/api/models/consent-type.ts | 1 + src/api/models/consent.ts | 35 -- src/api/models/index.ts | 1 - src/api/models/speaker.ts | 6 +- src/backend/index.ts | 31 +- src/components/Dialogs/ViewImageDialog.tsx | 14 +- .../ProjectUsers/ProjectSpeakers.tsx | 70 ++-- 13 files changed, 160 insertions(+), 545 deletions(-) delete mode 100644 src/api/models/consent.ts diff --git a/Backend.Tests/Models/SpeakerTests.cs b/Backend.Tests/Models/SpeakerTests.cs index 477696c5c8..a416103193 100644 --- a/Backend.Tests/Models/SpeakerTests.cs +++ b/Backend.Tests/Models/SpeakerTests.cs @@ -9,70 +9,41 @@ public class SpeakerTests private const string ProjectId = "SpeakerTestsProjectId"; private const string Name = "Ms. Given Family"; private const string FileName = "audio.mp3"; - private readonly Consent _consent = new() { FileName = FileName, FileType = ConsentType.Audio }; [Test] public void TestClone() { - var speakerA = new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = _consent }; + 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 = _consent }; + 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 = _consent } + 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 = _consent } + 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. Different", Consent = _consent } + 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 = new() } + 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 = _consent }.GetHashCode(); - Assert.That(new Speaker { Id = "diff-id", ProjectId = ProjectId, Name = Name, Consent = _consent } + 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 = _consent } + 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. Different", Consent = _consent } + 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 = new() } + Assert.That(new Speaker { Id = Id, ProjectId = ProjectId, Name = Name, Consent = ConsentType.Image } .GetHashCode(), Is.Not.EqualTo(code)); } } - - public class ConsentTests - { - private const string FileName = "audio.mp3"; - private readonly Consent _consent = new() { FileName = FileName, FileType = ConsentType.Audio }; - - [Test] - public void TestClone() - { - Assert.That(_consent.Equals(_consent.Clone()), Is.True); - } - - [Test] - public void TestEquals() - { - Assert.That(_consent.Equals(null), Is.False); - Assert.That(new Consent { FileName = "diff", FileType = ConsentType.Audio }.Equals(_consent), Is.False); - Assert.That(new Consent { FileName = FileName, FileType = ConsentType.Image }.Equals(_consent), Is.False); - } - - [Test] - public void TestHashCode() - { - var code = _consent.GetHashCode(); - Assert.That(new Consent { FileName = "diff", FileType = ConsentType.Audio }.GetHashCode(), Is.Not.EqualTo(code)); - Assert.That(new Consent { FileName = FileName, FileType = ConsentType.Image }.GetHashCode(), Is.Not.EqualTo(code)); - } - } } diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index af297234ca..a4dbd0fc34 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -122,7 +122,7 @@ public async Task DeleteSpeaker(string projectId, string speakerI /// Removes consent of the for specified projectId and speakerId /// Id of updated Speaker - [HttpGet("removeconsent/{speakerId}", Name = "RemoveConsent")] + [HttpDelete("consent/{speakerId}", Name = "RemoveConsent")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] public async Task RemoveConsent(string projectId, string speakerId) { @@ -141,22 +141,18 @@ public async Task RemoveConsent(string projectId, string speakerI } // Delete consent file - var filePath = speaker.Consent.FileName; - if (string.IsNullOrEmpty(filePath)) + if (speaker.Consent is ConsentType.None) { return StatusCode(StatusCodes.Status304NotModified, speakerId); } - if (speaker.Consent.FileType == ConsentType.Audio) + var path = FileStorage.GenerateConsentFilePath(speaker.Id); + if (IO.File.Exists(path)) { - filePath = FileStorage.GenerateAudioFilePath(projectId, filePath); - } - if (IO.File.Exists(filePath)) - { - IO.File.Delete(filePath); + IO.File.Delete(path); } // Update speaker and return result with id - speaker.Consent = new(); + speaker.Consent = ConsentType.None; return await _speakerRepo.Update(speakerId, speaker) switch { ResultOfUpdate.NotFound => NotFound(speakerId), @@ -195,16 +191,12 @@ public async Task UpdateSpeakerName(string projectId, string spea }; } - /// - /// Adds an audio consent from - /// locally to ~/.CombineFiles/{ProjectId}/Import/ExtractedLocation/Lift/audio - /// and updates the of the specified - /// + /// Saves a consent file locally and updates the specified /// Updated speaker - [HttpPost("uploadconsentaudio/{speakerId}", Name = "UploadConsentAudio")] + [HttpPost("consent/{speakerId}", Name = "UploadConsent")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] - public async Task UploadConsentAudio( - string projectId, string speakerId, [FromForm] FileUpload fileUpload) + public async Task UploadConsent( + string projectId, string speakerId, [FromForm] FileUpload upload) { // Sanitize user input try @@ -231,7 +223,7 @@ public async Task UploadConsentAudio( } // Ensure file is valid - var file = fileUpload.File; + var file = upload.File; if (file is null) { return BadRequest("Null File"); @@ -240,79 +232,27 @@ public async Task UploadConsentAudio( { return BadRequest("Empty File"); } - - // Copy file data to a local file with speakerId-dependent name - var path = FileStorage.GenerateAudioFilePathForWord(projectId, speakerId); - await using (var fs = new IO.FileStream(path, IO.FileMode.Create)) - { - await file.CopyToAsync(fs); - } - - // Update speaker consent and return result with speaker - var fileName = IO.Path.GetFileName(path); - speaker.Consent = new() { FileName = fileName, FileType = ConsentType.Audio }; - return await _speakerRepo.Update(speakerId, speaker) switch - { - ResultOfUpdate.NotFound => NotFound(speaker), - _ => Ok(speaker), - }; - } - - /// - /// Adds an image consent from - /// locally to ~/.CombineFiles/{ProjectId}/Avatars - /// and updates the of the specified - /// - /// Updated speaker - [HttpPost("uploadconsentimage/{speakerId}", Name = "UploadConsentImage")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Speaker))] - public async Task UploadConsentImage( - string projectId, string speakerId, [FromForm] FileUpload fileUpload) - { - // 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) + if (file.ContentType.Contains("audio")) { - return NotFound(speakerId); + speaker.Consent = ConsentType.Audio; } - - // Ensure file is valid - var file = fileUpload.File; - if (file is null) + else if (file.ContentType.Contains("image")) { - return BadRequest("Null File"); + speaker.Consent = ConsentType.Image; } - if (file.Length == 0) + else { - return BadRequest("Empty File"); + return BadRequest("File should be audio or image"); } // Copy file data to a new local file - var path = FileStorage.GenerateAvatarFilePath(speakerId); + var path = FileStorage.GenerateConsentFilePath(speakerId); await using (var fs = new IO.FileStream(path, IO.FileMode.OpenOrCreate)) { await file.CopyToAsync(fs); } - // Update speaker consent and return result with speaker - speaker.Consent = new() { FileName = path, FileType = ConsentType.Image }; + // Update and return speaker return await _speakerRepo.Update(speakerId, speaker) switch { ResultOfUpdate.NotFound => NotFound(speaker), @@ -321,10 +261,11 @@ public async Task UploadConsentImage( } /// Get speaker's consent - /// Stream of local image file - [HttpGet("downloadconsentimage/{speakerId}", Name = "DownloadConsentImage")] + /// Stream of local audio/image file + [AllowAnonymous] + [HttpGet("consent/{speakerId}", Name = "DownloadConsent")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FileContentResult))] - public IActionResult DownloadConsentImage(string speakerId) + 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)) @@ -333,7 +274,7 @@ public IActionResult DownloadConsentImage(string speakerId) // } // Ensure file exists - var path = FileStorage.GenerateAvatarFilePath(speakerId); + var path = FileStorage.GenerateConsentFilePath(speakerId); if (!IO.File.Exists(path)) { return NotFound(speakerId); diff --git a/Backend/Helper/FileStorage.cs b/Backend/Helper/FileStorage.cs index 6bf071cc96..d06915e856 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/Models/Speaker.cs b/Backend/Models/Speaker.cs index b42e418011..a71c95b97b 100644 --- a/Backend/Models/Speaker.cs +++ b/Backend/Models/Speaker.cs @@ -26,14 +26,13 @@ public class Speaker [Required] [BsonElement("consent")] - public Consent Consent { get; set; } + public ConsentType Consent { get; set; } public Speaker() { Id = ""; ProjectId = ""; Name = ""; - Consent = new Consent(); } public Speaker Clone() @@ -43,7 +42,7 @@ public Speaker Clone() Id = Id, ProjectId = ProjectId, Name = Name, - Consent = Consent.Clone() + Consent = Consent }; } @@ -51,7 +50,7 @@ public bool ContentEquals(Speaker other) { return ProjectId.Equals(other.ProjectId, StringComparison.Ordinal) && Name.Equals(other.Name, StringComparison.Ordinal) && - Consent.Equals(other.Consent); + Consent == other.Consent; } public override bool Equals(object? obj) @@ -66,43 +65,9 @@ public override int GetHashCode() } } - public class Consent - { - [Required] - [BsonElement("fileName")] - public string? FileName { get; set; } - - [Required] - [BsonElement("fileType")] - public ConsentType? FileType { get; set; } - - public Consent Clone() - { - return new Consent - { - FileName = FileName, - FileType = FileType - }; - } - - private bool ContentEquals(Consent other) - { - return FileName == other.FileName && FileType == other.FileType; - } - - public override bool Equals(object? obj) - { - return obj is Consent other && GetType() == obj.GetType() && ContentEquals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(FileName, FileType); - } - } - public enum ConsentType { + None = 0, Audio, Image } diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 330ce9e706..1130a9366a 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -25,7 +25,6 @@ models/autocomplete-setting.ts models/banner-type.ts models/chart-root-data.ts models/consent-type.ts -models/consent.ts models/credentials.ts models/custom-field.ts models/dataset.ts diff --git a/src/api/api/speaker-api.ts b/src/api/api/speaker-api.ts index 084376a200..e987fd43eb 100644 --- a/src/api/api/speaker-api.ts +++ b/src/api/api/speaker-api.ts @@ -197,17 +197,17 @@ export const SpeakerApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadConsentImage: async ( + downloadConsent: async ( speakerId: string, projectId: string, options: any = {} ): Promise => { // verify required parameter 'speakerId' is not null or undefined - assertParamExists("downloadConsentImage", "speakerId", speakerId); + assertParamExists("downloadConsent", "speakerId", speakerId); // verify required parameter 'projectId' is not null or undefined - assertParamExists("downloadConsentImage", "projectId", projectId); + assertParamExists("downloadConsent", "projectId", projectId); const localVarPath = - `/v1/projects/{projectId}/speakers/downloadconsentimage/{speakerId}` + `/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. @@ -349,7 +349,7 @@ export const SpeakerApiAxiosParamCreator = function ( // verify required parameter 'speakerId' is not null or undefined assertParamExists("removeConsent", "speakerId", speakerId); const localVarPath = - `/v1/projects/{projectId}/speakers/removeconsent/{speakerId}` + `/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. @@ -360,7 +360,7 @@ export const SpeakerApiAxiosParamCreator = function ( } const localVarRequestOptions = { - method: "GET", + method: "DELETE", ...baseOptions, ...options, }; @@ -445,7 +445,7 @@ export const SpeakerApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadConsentAudio: async ( + uploadConsent: async ( projectId: string, speakerId: string, file: any, @@ -454,96 +454,17 @@ export const SpeakerApiAxiosParamCreator = function ( options: any = {} ): Promise => { // verify required parameter 'projectId' is not null or undefined - assertParamExists("uploadConsentAudio", "projectId", projectId); + assertParamExists("uploadConsent", "projectId", projectId); // verify required parameter 'speakerId' is not null or undefined - assertParamExists("uploadConsentAudio", "speakerId", speakerId); + assertParamExists("uploadConsent", "speakerId", speakerId); // verify required parameter 'file' is not null or undefined - assertParamExists("uploadConsentAudio", "file", file); + assertParamExists("uploadConsent", "file", file); // verify required parameter 'name' is not null or undefined - assertParamExists("uploadConsentAudio", "name", name); + assertParamExists("uploadConsent", "name", name); // verify required parameter 'filePath' is not null or undefined - assertParamExists("uploadConsentAudio", "filePath", filePath); + assertParamExists("uploadConsent", "filePath", filePath); const localVarPath = - `/v1/projects/{projectId}/speakers/uploadconsentaudio/{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, - }; - }, - /** - * - * @param {string} projectId - * @param {string} speakerId - * @param {any} file - * @param {string} name - * @param {string} filePath - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - uploadConsentImage: async ( - projectId: string, - speakerId: string, - file: any, - name: string, - filePath: string, - options: any = {} - ): Promise => { - // verify required parameter 'projectId' is not null or undefined - assertParamExists("uploadConsentImage", "projectId", projectId); - // verify required parameter 'speakerId' is not null or undefined - assertParamExists("uploadConsentImage", "speakerId", speakerId); - // verify required parameter 'file' is not null or undefined - assertParamExists("uploadConsentImage", "file", file); - // verify required parameter 'name' is not null or undefined - assertParamExists("uploadConsentImage", "name", name); - // verify required parameter 'filePath' is not null or undefined - assertParamExists("uploadConsentImage", "filePath", filePath); - const localVarPath = - `/v1/projects/{projectId}/speakers/uploadconsentimage/{speakerId}` + `/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. @@ -686,19 +607,18 @@ export const SpeakerApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async downloadConsentImage( + async downloadConsent( speakerId: string, projectId: string, options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.downloadConsentImage( - speakerId, - projectId, - options - ); + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadConsent( + speakerId, + projectId, + options + ); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -819,7 +739,7 @@ export const SpeakerApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async uploadConsentAudio( + async uploadConsent( projectId: string, speakerId: string, file: any, @@ -829,51 +749,14 @@ export const SpeakerApiFp = function (configuration?: Configuration) { ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.uploadConsentAudio( - projectId, - speakerId, - file, - name, - filePath, - options - ); - return createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadConsent( + projectId, + speakerId, + file, + name, + filePath, + options ); - }, - /** - * - * @param {string} projectId - * @param {string} speakerId - * @param {any} file - * @param {string} name - * @param {string} filePath - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async uploadConsentImage( - projectId: string, - speakerId: string, - file: any, - name: string, - filePath: string, - options?: any - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise - > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.uploadConsentImage( - projectId, - speakerId, - file, - name, - filePath, - options - ); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -948,13 +831,13 @@ export const SpeakerApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadConsentImage( + downloadConsent( speakerId: string, projectId: string, options?: any ): AxiosPromise { return localVarFp - .downloadConsentImage(speakerId, projectId, options) + .downloadConsent(speakerId, projectId, options) .then((request) => request(axios, basePath)); }, /** @@ -1031,7 +914,7 @@ export const SpeakerApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadConsentAudio( + uploadConsent( projectId: string, speakerId: string, file: any, @@ -1040,29 +923,7 @@ export const SpeakerApiFactory = function ( options?: any ): AxiosPromise { return localVarFp - .uploadConsentAudio(projectId, speakerId, file, name, filePath, 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} - */ - uploadConsentImage( - projectId: string, - speakerId: string, - file: any, - name: string, - filePath: string, - options?: any - ): AxiosPromise { - return localVarFp - .uploadConsentImage(projectId, speakerId, file, name, filePath, options) + .uploadConsent(projectId, speakerId, file, name, filePath, options) .then((request) => request(axios, basePath)); }, }; @@ -1125,22 +986,22 @@ export interface SpeakerApiDeleteSpeakerRequest { } /** - * Request parameters for downloadConsentImage operation in SpeakerApi. + * Request parameters for downloadConsent operation in SpeakerApi. * @export - * @interface SpeakerApiDownloadConsentImageRequest + * @interface SpeakerApiDownloadConsentRequest */ -export interface SpeakerApiDownloadConsentImageRequest { +export interface SpeakerApiDownloadConsentRequest { /** * * @type {string} - * @memberof SpeakerApiDownloadConsentImage + * @memberof SpeakerApiDownloadConsent */ readonly speakerId: string; /** * * @type {string} - * @memberof SpeakerApiDownloadConsentImage + * @memberof SpeakerApiDownloadConsent */ readonly projectId: string; } @@ -1230,85 +1091,43 @@ export interface SpeakerApiUpdateSpeakerNameRequest { } /** - * Request parameters for uploadConsentAudio operation in SpeakerApi. - * @export - * @interface SpeakerApiUploadConsentAudioRequest - */ -export interface SpeakerApiUploadConsentAudioRequest { - /** - * - * @type {string} - * @memberof SpeakerApiUploadConsentAudio - */ - readonly projectId: string; - - /** - * - * @type {string} - * @memberof SpeakerApiUploadConsentAudio - */ - readonly speakerId: string; - - /** - * - * @type {any} - * @memberof SpeakerApiUploadConsentAudio - */ - readonly file: any; - - /** - * - * @type {string} - * @memberof SpeakerApiUploadConsentAudio - */ - readonly name: string; - - /** - * - * @type {string} - * @memberof SpeakerApiUploadConsentAudio - */ - readonly filePath: string; -} - -/** - * Request parameters for uploadConsentImage operation in SpeakerApi. + * Request parameters for uploadConsent operation in SpeakerApi. * @export - * @interface SpeakerApiUploadConsentImageRequest + * @interface SpeakerApiUploadConsentRequest */ -export interface SpeakerApiUploadConsentImageRequest { +export interface SpeakerApiUploadConsentRequest { /** * * @type {string} - * @memberof SpeakerApiUploadConsentImage + * @memberof SpeakerApiUploadConsent */ readonly projectId: string; /** * * @type {string} - * @memberof SpeakerApiUploadConsentImage + * @memberof SpeakerApiUploadConsent */ readonly speakerId: string; /** * * @type {any} - * @memberof SpeakerApiUploadConsentImage + * @memberof SpeakerApiUploadConsent */ readonly file: any; /** * * @type {string} - * @memberof SpeakerApiUploadConsentImage + * @memberof SpeakerApiUploadConsent */ readonly name: string; /** * * @type {string} - * @memberof SpeakerApiUploadConsentImage + * @memberof SpeakerApiUploadConsent */ readonly filePath: string; } @@ -1378,17 +1197,17 @@ export class SpeakerApi extends BaseAPI { /** * - * @param {SpeakerApiDownloadConsentImageRequest} requestParameters Request parameters. + * @param {SpeakerApiDownloadConsentRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SpeakerApi */ - public downloadConsentImage( - requestParameters: SpeakerApiDownloadConsentImageRequest, + public downloadConsent( + requestParameters: SpeakerApiDownloadConsentRequest, options?: any ) { return SpeakerApiFp(this.configuration) - .downloadConsentImage( + .downloadConsent( requestParameters.speakerId, requestParameters.projectId, options @@ -1475,40 +1294,17 @@ export class SpeakerApi extends BaseAPI { /** * - * @param {SpeakerApiUploadConsentAudioRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof SpeakerApi - */ - public uploadConsentAudio( - requestParameters: SpeakerApiUploadConsentAudioRequest, - options?: any - ) { - return SpeakerApiFp(this.configuration) - .uploadConsentAudio( - requestParameters.projectId, - requestParameters.speakerId, - requestParameters.file, - requestParameters.name, - requestParameters.filePath, - options - ) - .then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {SpeakerApiUploadConsentImageRequest} requestParameters Request parameters. + * @param {SpeakerApiUploadConsentRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SpeakerApi */ - public uploadConsentImage( - requestParameters: SpeakerApiUploadConsentImageRequest, + public uploadConsent( + requestParameters: SpeakerApiUploadConsentRequest, options?: any ) { return SpeakerApiFp(this.configuration) - .uploadConsentImage( + .uploadConsent( requestParameters.projectId, requestParameters.speakerId, requestParameters.file, diff --git a/src/api/models/consent-type.ts b/src/api/models/consent-type.ts index 15f1d7b059..450a6e3c9e 100644 --- a/src/api/models/consent-type.ts +++ b/src/api/models/consent-type.ts @@ -18,6 +18,7 @@ * @enum {string} */ export enum ConsentType { + None = "None", Audio = "Audio", Image = "Image", } diff --git a/src/api/models/consent.ts b/src/api/models/consent.ts deleted file mode 100644 index 18f5f14f17..0000000000 --- a/src/api/models/consent.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* 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 Consent - */ -export interface Consent { - /** - * - * @type {string} - * @memberof Consent - */ - fileName: string; - /** - * - * @type {ConsentType} - * @memberof Consent - */ - fileType: ConsentType; -} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index ed70f66d52..791427926d 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,7 +1,6 @@ export * from "./autocomplete-setting"; export * from "./banner-type"; export * from "./chart-root-data"; -export * from "./consent"; export * from "./consent-type"; export * from "./credentials"; export * from "./custom-field"; diff --git a/src/api/models/speaker.ts b/src/api/models/speaker.ts index 38e72df6a0..5711fc75d1 100644 --- a/src/api/models/speaker.ts +++ b/src/api/models/speaker.ts @@ -12,7 +12,7 @@ * Do not edit the class manually. */ -import { Consent } from "./consent"; +import { ConsentType } from "./consent-type"; /** * @@ -40,8 +40,8 @@ export interface Speaker { name: string; /** * - * @type {Consent} + * @type {ConsentType} * @memberof Speaker */ - consent: Consent; + consent: ConsentType; } diff --git a/src/backend/index.ts b/src/backend/index.ts index 95bc1de3d7..9a375b41d1 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -8,7 +8,6 @@ import { BASE_PATH } from "api/base"; import { BannerType, ChartRootData, - ConsentType, EmailInviteStatus, MergeUndoIds, MergeWords, @@ -144,9 +143,8 @@ export async function deleteAudio( } // Use of the returned url acts as an HttpGet. -export function getAudioUrl(id: string, fileName: string): string { - const projId = LocalStorage.getProjectId(); - return `${apiBaseURL}/projects/${projId}/words/${id}/audio/download/${fileName}`; +export function getAudioUrl(wordId: string, fileName: string): string { + return `${apiBaseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; } /* AvatarController.cs */ @@ -496,12 +494,9 @@ export async function deleteSpeaker( /** Remove consent of specified speaker (in current project if no projectId given). * Returns id of updated speaker. */ -export async function removeConsent( - speakerId: string, - projectId?: string -): Promise { - projectId = projectId || LocalStorage.getProjectId(); - const params = { projectId, speakerId }; +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; } @@ -521,24 +516,24 @@ export async function updateSpeakerName( * Returns updated speaker. */ export async function uploadConsent( speaker: Speaker, - file: File, - fileType: ConsentType + file: File ): Promise { const { id, projectId } = speaker; const params = { projectId, speakerId: id, ...fileUpload(file) }; const headers = { ...authHeader(), "content-type": "application/json" }; - const response = - fileType === ConsentType.Audio - ? await speakerApi.uploadConsentAudio(params, { headers }) - : await speakerApi.uploadConsentImage(params, { headers }); - return response.data; + 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.downloadConsentImage(params, options); + const resp = await speakerApi.downloadConsent(params, options); const image = Base64.btoa( new Uint8Array(resp.data).reduce( (data, byte) => data + String.fromCharCode(byte), diff --git a/src/components/Dialogs/ViewImageDialog.tsx b/src/components/Dialogs/ViewImageDialog.tsx index a43e618bee..00ff8b4d60 100644 --- a/src/components/Dialogs/ViewImageDialog.tsx +++ b/src/components/Dialogs/ViewImageDialog.tsx @@ -1,9 +1,8 @@ import { Dialog, DialogContent, DialogTitle, Grid } from "@mui/material"; -import { ReactElement, useState } from "react"; +import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { CloseButton, DeleteButtonWithDialog } from "components/Buttons"; -import { CancelConfirmDialog } from "components/Dialogs"; interface ViewImageDialogProps { close: () => void; @@ -18,12 +17,9 @@ interface ViewImageDialogProps { export default function ViewImageDialog( props: ViewImageDialogProps ): ReactElement { - const [dialogOpen, setDialogOpen] = useState(false); - const { t } = useTranslation(); const handleDelete = async (): Promise => { - setDialogOpen(false); if (props.deleteImage) { await props.deleteImage(); } @@ -40,15 +36,9 @@ export default function ViewImageDialog( - setDialogOpen(false)} - handleConfirm={handleDelete} - /> setDialogOpen(true)} + delete={handleDelete} textId={props.deleteTextId ?? ""} /> diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index 3490d69e18..a5ebeb340d 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -1,20 +1,14 @@ import { Add, AddPhotoAlternate, Edit, Image, Mic } from "@mui/icons-material"; import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; -import { - Fragment, - ReactElement, - useCallback, - useEffect, - useState, -} from "react"; +import { ReactElement, useCallback, useEffect, useState } from "react"; import { ConsentType, Speaker } from "api/models"; import { createSpeaker, deleteSpeaker, getAllSpeakers, - getAudioUrl, getConsentImageSrc, + getConsentUrl, removeConsent, updateSpeakerName, uploadConsent, @@ -72,33 +66,33 @@ interface ProjSpeakerProps { } function SpeakerListItem(props: ProjSpeakerProps): ReactElement { - const { consent, id, name, projectId } = props.speaker; - const consentButton = !consent.fileName ? ( - - ) : consent.fileType === ConsentType.Audio ? ( - - removeConsent(id, projectId)} - fileName={consent.fileName} - pronunciationUrl={getAudioUrl(id, consent.fileName)} - size={"small"} - warningTextId="projectSettings.speaker.consent.warning" - /> - - ) : consent.fileType === ConsentType.Image ? ( - - ) : ( - - ); + const { consent, id, name } = props.speaker; + const consentButtons = + consent === ConsentType.Audio ? ( + + removeConsent(props.speaker)} + fileName={id} + pronunciationUrl={getConsentUrl(props.speaker)} + size={"small"} + warningTextId="projectSettings.speaker.consent.warning" + /> + + ) : consent === ConsentType.Image ? ( + + ) : ( + <> + + + + ); return ( - - {consentButton} - - - + + + {consentButtons} ); } @@ -112,7 +106,8 @@ function ViewConsentImageIcon(props: ProjSpeakerProps): ReactElement { }, [props.speaker]); const handleDeleteImage = async (): Promise => { - await removeConsent(props.speaker.id, props.speaker.projectId); + await removeConsent(props.speaker); + setOpen(false); await props.refresh(); }; @@ -140,7 +135,8 @@ function RecordConsentAudioIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); const handleUploadAudio = async (audioFile: File): Promise => { - await uploadConsent(props.speaker, audioFile, ConsentType.Audio); + await uploadConsent(props.speaker, audioFile); + setOpen(false); await props.refresh(); }; @@ -167,7 +163,7 @@ function UploadConsentImageIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); const handleUploadImage = async (imageFile: File): Promise => { - await uploadConsent(props.speaker, imageFile, ConsentType.Image); + await uploadConsent(props.speaker, imageFile); await props.refresh(); }; @@ -233,9 +229,9 @@ function DeleteSpeakerIcon(props: ProjSpeakerProps): ReactElement { buttonIdConfirm="project-speakers-delete-confirm" delete={handleDelete} textId={ - props.speaker.consent.fileName - ? "projectSettings.speaker.consent.warning" - : "projectSettings.speaker.delete" + props.speaker.consent === ConsentType.None + ? "projectSettings.speaker.delete" + : "projectSettings.speaker.consent.warning" } tooltipTextId="projectSettings.speaker.delete" /> From 87988394c93d07c435f9875d2cc2c0cc37c0477a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 20 Nov 2023 17:58:46 -0500 Subject: [PATCH 14/56] Fix comment --- src/components/Dialogs/SubmitTextDialog.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/Dialogs/SubmitTextDialog.tsx b/src/components/Dialogs/SubmitTextDialog.tsx index f1bfbd433d..d5f959a361 100644 --- a/src/components/Dialogs/SubmitTextDialog.tsx +++ b/src/components/Dialogs/SubmitTextDialog.tsx @@ -25,9 +25,7 @@ interface EditTextDialogProps { textFieldId?: string; } -/** - * Dialog for editing text and confirm or cancel the edit - */ +/** Dialog for submitting new text */ export default function SubmitTextDialog( props: EditTextDialogProps ): ReactElement { From 94579148e1410d4a4b0e8b6a99353518012a6b31 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 30 Nov 2023 12:44:46 -0500 Subject: [PATCH 15/56] Add speaker menu --- public/locales/en/translation.json | 3 + src/backend/index.ts | 5 +- src/components/AppBar/ProjectButtons.tsx | 11 +- src/components/AppBar/SpeakerMenu.tsx | 122 ++++++++++++++++++ .../AppBar/tests/ProjectButtons.test.tsx | 2 + .../ProjectUsers/ProjectSpeakers.tsx | 6 +- 6 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 src/components/AppBar/SpeakerMenu.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 165a185621..c66b617571 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -79,6 +79,9 @@ "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": { + "other": "[None of the above]" + }, "userMenu": { "siteSettings": "Site Settings", "userSettings": "User Settings", diff --git a/src/backend/index.ts b/src/backend/index.ts index 9a375b41d1..d4b2bcea75 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -464,10 +464,11 @@ export async function getSemanticDomainTreeNodeByName( /* SpeakerController.cs */ /** Get all speakers (in current project if no projectId given). - * Returns array of speakers. */ + * Returns array of speakers, sorted alphabetically by name. */ export async function getAllSpeakers(projectId?: string): Promise { const params = { projectId: projectId || LocalStorage.getProjectId() }; - return (await speakerApi.getProjectSpeakers(params, defaultOptions())).data; + const resp = await speakerApi.getProjectSpeakers(params, defaultOptions()); + return resp.data.sort((a, b) => a.name.localeCompare(b.name)); } /** Creates new speaker (in current project if no projectId given). diff --git a/src/components/AppBar/ProjectButtons.tsx b/src/components/AppBar/ProjectButtons.tsx index 0661cc04d0..403b837ac8 100644 --- a/src/components/AppBar/ProjectButtons.tsx +++ b/src/components/AppBar/ProjectButtons.tsx @@ -14,7 +14,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"; @@ -38,7 +40,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(); @@ -95,6 +103,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..f1ae2dff4d --- /dev/null +++ b/src/components/AppBar/SpeakerMenu.tsx @@ -0,0 +1,122 @@ +import { Circle, RecordVoiceOver } from "@mui/icons-material"; +import { + Button, + Divider, + Icon, + ListItemIcon, + ListItemText, + Menu, + MenuItem, +} 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 [anchorElement, setAnchorElement] = useState(); + + function handleClick(event: MouseEvent): void { + setAnchorElement(event.currentTarget); + } + + function handleClose(): void { + setAnchorElement(undefined); + } + + return ( + <> + + + + + + ); +} + +interface SpeakerMenuListProps { + forwardedRef?: ForwardedRef; +} + +/** SpeakerMenu options */ +export function SpeakerMenuList(props: SpeakerMenuListProps): ReactElement { + const dispatch = useAppDispatch(); + const currentProjId = useSelector( + (state: StoreState) => state.currentProjectState.project.id + ); + const currentSpeaker = useSelector( + (state: StoreState) => state.currentProjectState.speaker + ); + const [speakers, setSpeakers] = useState([]); + const { t } = useTranslation(); + + useEffect(() => { + if (currentProjId) { + getAllSpeakers(currentProjId).then(setSpeakers); + } + }, [currentProjId]); + + const currentIcon = ( + + ); + + const speakerMenuItem = (speaker?: Speaker): ReactElement => { + const isCurrent = speaker?.id === currentSpeaker?.id; + return ( + (isCurrent ? {} : dispatch(setCurrentSpeaker(speaker)))} + > + {isCurrent ? currentIcon : } + {speaker?.name ?? t("speakerMenu.other")} + + ); + }; + + return ( +
+ {speakers.map((s) => speakerMenuItem(s))} + + {speakerMenuItem()} +
+ ); +} diff --git a/src/components/AppBar/tests/ProjectButtons.test.tsx b/src/components/AppBar/tests/ProjectButtons.test.tsx index ed6060897c..8b36a857b3 100644 --- a/src/components/AppBar/tests/ProjectButtons.test.tsx +++ b/src/components/AppBar/tests/ProjectButtons.test.tsx @@ -11,6 +11,7 @@ import ProjectButtons, { projButtonId, statButtonId, } from "components/AppBar/ProjectButtons"; +import { Goal } from "types/goals"; import { Path } from "types/path"; import { themeColors } from "types/theme"; @@ -34,6 +35,7 @@ let testRenderer: ReactTestRenderer; const mockStore = configureMockStore()({ currentProjectState: { project: { name: "" } }, + goalsState: { currentGoal: new Goal() }, }); const renderProjectButtons = async (path = Path.Root): Promise => { diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakers.tsx index a5ebeb340d..b2b23f288b 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakers.tsx @@ -32,9 +32,9 @@ export default function ProjectSpeakers(props: { const [projSpeakers, setProjSpeakers] = useState([]); const getProjectSpeakers = useCallback(() => { - getAllSpeakers(props.projectId).then((speakers) => - setProjSpeakers(speakers.sort((a, b) => a.name.localeCompare(b.name))) - ); + if (props.projectId) { + getAllSpeakers(props.projectId).then(setProjSpeakers); + } }, [props.projectId]); useEffect(() => { From b9499d892918a9c5402b3354ecce24ef901f0687 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 1 Dec 2023 15:37:26 -0500 Subject: [PATCH 16/56] Consolidate consent icon --- public/locales/en/translation.json | 5 +- .../Buttons/IconButtonWithTooltip.tsx | 4 +- src/components/ProjectSettings/index.tsx | 4 +- ...ctSpeakers.tsx => ProjectSpeakersList.tsx} | 140 ++------------- .../SpeakerConsentListItemIcon.tsx | 166 ++++++++++++++++++ 5 files changed, 184 insertions(+), 135 deletions(-) rename src/components/ProjectUsers/{ProjectSpeakers.tsx => ProjectSpeakersList.tsx} (50%) create mode 100644 src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c67a8483b8..c0dcf99e4a 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -209,9 +209,10 @@ "edit": "Edit speaker's name", "consent": { "play": "Listen to the audio consent for this speaker", - "record": "Record audio consent for this speaker", + "add": "Add consent for this speaker", + "record": "Record audio consent", "look": "Look at the image consent for this speaker", - "upload": "Upload image consent for this speaker", + "upload": "Upload image consent", "remove": "Remove this speaker's consent", "warning": "Warning: the speaker's current consent will be deleted--this cannot be undone." } diff --git a/src/components/Buttons/IconButtonWithTooltip.tsx b/src/components/Buttons/IconButtonWithTooltip.tsx index 90bf775e64..51527916db 100644 --- a/src/components/Buttons/IconButtonWithTooltip.tsx +++ b/src/components/Buttons/IconButtonWithTooltip.tsx @@ -1,5 +1,5 @@ import { Tooltip, IconButton } from "@mui/material"; -import { ReactElement, ReactNode } from "react"; +import { MouseEventHandler, ReactElement, ReactNode } from "react"; import { useTranslation } from "react-i18next"; interface IconButtonWithTooltipProps { @@ -7,7 +7,7 @@ interface IconButtonWithTooltipProps { text?: ReactNode; textId?: string; size?: "large" | "medium" | "small"; - onClick?: () => void; + onClick?: MouseEventHandler; buttonId: string; side?: "bottom" | "left" | "right" | "top"; } diff --git a/src/components/ProjectSettings/index.tsx b/src/components/ProjectSettings/index.tsx index 2453b7d203..05090a3797 100644 --- a/src/components/ProjectSettings/index.tsx +++ b/src/components/ProjectSettings/index.tsx @@ -51,7 +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 ProjectSpeakers from "components/ProjectUsers/ProjectSpeakers"; +import ProjectSpeakersList from "components/ProjectUsers/ProjectSpeakersList"; import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; import { Path } from "types/path"; @@ -238,7 +238,7 @@ export default function ProjectSettingsComponent(): ReactElement { } title={t("projectSettings.speaker.label")} - body={} + body={} /> )}
diff --git a/src/components/ProjectUsers/ProjectSpeakers.tsx b/src/components/ProjectUsers/ProjectSpeakersList.tsx similarity index 50% rename from src/components/ProjectUsers/ProjectSpeakers.tsx rename to src/components/ProjectUsers/ProjectSpeakersList.tsx index b2b23f288b..839f69cdb3 100644 --- a/src/components/ProjectUsers/ProjectSpeakers.tsx +++ b/src/components/ProjectUsers/ProjectSpeakersList.tsx @@ -1,4 +1,4 @@ -import { Add, AddPhotoAlternate, Edit, Image, Mic } from "@mui/icons-material"; +import { Add, Edit } from "@mui/icons-material"; import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; import { ReactElement, useCallback, useEffect, useState } from "react"; @@ -7,26 +7,16 @@ import { createSpeaker, deleteSpeaker, getAllSpeakers, - getConsentImageSrc, - getConsentUrl, - removeConsent, updateSpeakerName, - uploadConsent, } from "backend"; import { DeleteButtonWithDialog, IconButtonWithTooltip, } from "components/Buttons"; -import { - EditTextDialog, - RecordAudioDialog, - SubmitTextDialog, - UploadImageDialog, - ViewImageDialog, -} from "components/Dialogs"; -import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import { EditTextDialog, SubmitTextDialog } from "components/Dialogs"; +import SpeakerConsentListItemIcon from "components/ProjectUsers/SpeakerConsentListItemIcon"; -export default function ProjectSpeakers(props: { +export default function ProjectSpeakersList(props: { projectId: string; }): ReactElement { const [projSpeakers, setProjSpeakers] = useState([]); @@ -66,126 +56,18 @@ interface ProjSpeakerProps { } function SpeakerListItem(props: ProjSpeakerProps): ReactElement { - const { consent, id, name } = props.speaker; - const consentButtons = - consent === ConsentType.Audio ? ( - - removeConsent(props.speaker)} - fileName={id} - pronunciationUrl={getConsentUrl(props.speaker)} - size={"small"} - warningTextId="projectSettings.speaker.consent.warning" - /> - - ) : consent === ConsentType.Image ? ( - - ) : ( - <> - - - - ); - + const { refresh, speaker } = props; return ( - - - - {consentButtons} + + + + ); } -function ViewConsentImageIcon(props: ProjSpeakerProps): ReactElement { - const [imgSrc, setImgSrc] = useState(""); - const [open, setOpen] = useState(false); - - 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.look" - /> - setOpen(false)} - imgSrc={imgSrc} - open={open} - titleId="projectSettings.speaker.consent.look" - deleteImage={handleDeleteImage} - deleteTextId="projectSettings.speaker.consent.remove" - /> - - ); -} - -function RecordConsentAudioIcon(props: ProjSpeakerProps): ReactElement { - const [open, setOpen] = useState(false); - - const handleUploadAudio = async (audioFile: File): Promise => { - await uploadConsent(props.speaker, audioFile); - setOpen(false); - await props.refresh(); - }; - - return ( - - } - onClick={() => setOpen(true)} - textId="projectSettings.speaker.consent.record" - /> - setOpen(false)} - open={open} - titleId="projectSettings.speaker.consent.record" - uploadAudio={handleUploadAudio} - /> - - ); -} - -function UploadConsentImageIcon(props: ProjSpeakerProps): ReactElement { - const [open, setOpen] = useState(false); - - const handleUploadImage = async (imageFile: File): Promise => { - await uploadConsent(props.speaker, imageFile); - await props.refresh(); - }; - - return ( - - } - onClick={() => setOpen(true)} - textId="projectSettings.speaker.consent.upload" - /> - setOpen(false)} - open={open} - titleId="projectSettings.speaker.consent.upload" - uploadImage={handleUploadImage} - /> - - ); -} - -function EditSpeakerNameIcon(props: ProjSpeakerProps): ReactElement { +function EditSpeakerNameListItemIcon(props: ProjSpeakerProps): ReactElement { const [open, setOpen] = useState(false); const handleUpdateText = async (name: string): Promise => { @@ -215,7 +97,7 @@ function EditSpeakerNameIcon(props: ProjSpeakerProps): ReactElement { ); } -function DeleteSpeakerIcon(props: ProjSpeakerProps): ReactElement { +function DeleteSpeakerListItemIcon(props: ProjSpeakerProps): ReactElement { const handleDelete = async (): Promise => { await deleteSpeaker(props.speaker.id, props.projectId); await props.refresh(); diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx new file mode 100644 index 0000000000..b3aa5fe49f --- /dev/null +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -0,0 +1,166 @@ +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"; + +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); + + 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.look" + /> + setOpen(false)} + imgSrc={imgSrc} + open={open} + titleId="projectSettings.speaker.consent.look" + 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} + /> + + ); +} From c97d9b24dcb7a8e57e9105ecc9612dcf3c46be9f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 1 Dec 2023 17:39:25 -0500 Subject: [PATCH 17/56] Add Pronunciation model for audio --- .../Controllers/AudioControllerTests.cs | 12 ++-- .../Controllers/LiftControllerTests.cs | 4 +- Backend.Tests/Models/WordTests.cs | 4 +- Backend.Tests/Util.cs | 2 +- Backend/Controllers/AudioController.cs | 2 +- Backend/Models/Word.cs | 63 ++++++++++++++-- Backend/Services/LiftService.cs | 8 +-- Backend/Services/MergeService.cs | 1 + Backend/Services/WordService.cs | 12 ++-- src/api/.openapi-generator/FILES | 1 + src/api/models/index.ts | 1 + src/api/models/pronunciation.ts | 33 +++++++++ src/api/models/word.ts | 5 +- .../DataEntryTable/NewEntry/index.tsx | 8 +-- .../NewEntry/tests/index.test.tsx | 2 +- .../DataEntry/DataEntryTable/RecentEntry.tsx | 2 +- .../DataEntry/DataEntryTable/index.tsx | 71 +++++++++++-------- .../DataEntryTable/tests/RecentEntry.test.tsx | 5 +- .../Pronunciations/PronunciationsBackend.tsx | 24 +++---- .../Pronunciations/PronunciationsFrontend.tsx | 23 +++--- .../tests/PronunciationsBackend.test.tsx | 5 +- .../tests/PronunciationsFrontend.test.tsx | 5 +- src/components/WordCard/index.tsx | 2 +- src/components/WordCard/tests/index.test.tsx | 5 +- .../Redux/ReviewEntriesActions.ts | 8 +-- .../ReviewEntriesTable/CellColumns.tsx | 20 +++--- .../CellComponents/PronunciationsCell.tsx | 11 +-- .../tests/PronunciationsCell.test.tsx | 25 ++++--- src/goals/ReviewEntries/ReviewEntriesTypes.ts | 5 +- src/types/word.ts | 5 ++ 30 files changed, 247 insertions(+), 127 deletions(-) create mode 100644 src/api/models/pronunciation.ts diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 7801e33e54..401632f1d5 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -97,13 +97,13 @@ public void TestAudioImport() public void DeleteAudio() { // Fill test database - var origWord = _wordRepo.Create(Util.RandomWord(_projId)).Result; - - // Add audio file to word - origWord.Audio.Add("a.wav"); + 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 +119,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..55ff1bd646 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -518,9 +518,9 @@ public void TestRoundtrip(RoundTripObj roundTripObj) { Assert.That(allWords[0].Senses[0].Guid.ToString(), Is.EqualTo(roundTripObj.SenseGuid)); } - foreach (var audioFile in allWords[0].Audio) + foreach (var audio in allWords[0].Audio) { - Assert.That(roundTripObj.AudioFiles, Does.Contain(Path.GetFileName(audioFile))); + Assert.That(roundTripObj.AudioFiles, Does.Contain(Path.GetFileName(audio.FileName))); } } diff --git a/Backend.Tests/Models/WordTests.cs b/Backend.Tests/Models/WordTests.cs index d0af74eb7c..b1bf61cac1 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,7 +100,7 @@ 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); 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/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index 4ccd93df5e..bb6e93ddb5 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -115,7 +115,7 @@ public async Task UploadAudioFile(string projectId, string wordId { return NotFound(wordId); } - word.Audio.Add(Path.GetFileName(fileUpload.FilePath)); + word.Audio.Add(new Pronunciation(Path.GetFileName(fileUpload.FilePath))); // Update the word with new audio file await _wordService.Update(projectId, userId, wordId, word); diff --git a/Backend/Models/Word.cs b/Backend/Models/Word.cs index 69640efe80..5e37ac9728 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) { @@ -253,6 +253,59 @@ public bool AppendContainedWordContents(Word other, String userId) } } + /// A pronunciation associated with a Word. + public class Pronunciation + { + /// The audio file name. + [Required] + public string FileName { get; set; } + + /// The speaker id. + [Required] + public string SpeakerId { get; set; } + + public Pronunciation() + { + FileName = ""; + SpeakerId = ""; + } + + 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 + }; + } + + 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); + } + + public override int GetHashCode() + { + return HashCode.Combine(FileName, SpeakerId); + } + } + /// A note associated with a Word, compatible with FieldWorks. public class Note { diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index b9243c3f52..2c25577e81 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -505,11 +505,11 @@ 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) { - foreach (var audioFile in wordEntry.Audio) + foreach (var audio in wordEntry.Audio) { 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)) @@ -809,7 +809,7 @@ public void FinishEntry(LiftEntry entry) // 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); + newWord.Audio.Add(new Pronunciation(audioFile)); } } diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index c045745a71..58f012df1c 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -50,6 +50,7 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords } // Remove duplicates. + // TODO: Confirm this works with Audio now List 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/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 1130a9366a..58f54943ea 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -47,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 diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 791427926d..33539e6fa0 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -23,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"; diff --git a/src/api/models/pronunciation.ts b/src/api/models/pronunciation.ts new file mode 100644 index 0000000000..02de2a54e6 --- /dev/null +++ b/src/api/models/pronunciation.ts @@ -0,0 +1,33 @@ +/* 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; +} 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/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index d1d6dde042..1d6b31c44c 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, @@ -45,7 +45,7 @@ interface NewEntryProps { addNewEntry: () => Promise; resetNewEntry: () => void; updateWordWithNewGloss: (wordId: string) => Promise; - newAudioUrls: string[]; + newAudio: Pronunciation[]; addNewAudioUrl: (file: File) => void; delNewAudioUrl: (url: string) => void; newGloss: string; @@ -73,7 +73,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement { addNewEntry, resetNewEntry, updateWordWithNewGloss, - newAudioUrls, + newAudio, addNewAudioUrl, delNewAudioUrl, newGloss, @@ -291,7 +291,7 @@ 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 d46ad479cd..11e3084d6f 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -26,7 +26,7 @@ describe("NewEntry", () => { addNewEntry={jest.fn()} resetNewEntry={jest.fn()} updateWordWithNewGloss={jest.fn()} - newAudioUrls={[]} + newAudio={[]} addNewAudioUrl={jest.fn()} delNewAudioUrl={jest.fn()} newGloss={""} diff --git a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx index de27a2b4d3..65529941fb 100644 --- a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx @@ -134,7 +134,7 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { > {!props.disabled && ( { props.deleteAudioFromWord(props.entry.id, fileName); diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index 8c44cfac8a..82e8f750cb 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, @@ -34,7 +35,13 @@ 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 { + newNote, + newPronunciation, + newSense, + newWord, + simpleWord, +} from "types/word"; import { defaultWritingSystem } from "types/writingSystem"; import SpellCheckerContext from "utilities/spellCheckerContext"; import { LevenshteinDistance } from "utilities/utilities"; @@ -183,7 +190,7 @@ interface DataEntryTableState { isFetchingFrontier: boolean; senseSwitches: SenseSwitch[]; // new entry state - newAudioUrls: string[]; + newAudio: Pronunciation[]; newGloss: string; newNote: string; newVern: string; @@ -224,7 +231,7 @@ export default function DataEntryTable( isFetchingFrontier: true, senseSwitches: [], // new entry state - newAudioUrls: [], + newAudio: [], newGloss: "", newNote: "", newVern: "", @@ -356,27 +363,27 @@ export default function DataEntryTable( const resetNewEntry = (): void => { setState((prevState) => ({ ...prevState, - newAudioUrls: [], + newAudio: [], newGloss: "", newNote: "", newVern: "", })); }; - /*** Add an audio file to newAudioUrls. */ + /*** Add an audio file to newAudio. */ const addNewAudioUrl = (file: File): 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))); + return { ...prevState, newAudio }; }); }; - /*** Delete a url from newAudioUrls. */ + /*** Delete a url from newAudio. */ const delNewAudioUrl = (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 }; }); }; @@ -424,7 +431,7 @@ export default function DataEntryTable( recentWords: [], senseSwitches: [], // new entry state: - newAudioUrls: [], + newAudio: [], newGloss: "", newNote: "", newVern: "", @@ -565,14 +572,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 uploadFileFromUrl(newId, a.fileName); } defunctWord(oldId, newId); return newId; @@ -595,7 +602,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; @@ -603,7 +614,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)); @@ -641,7 +652,7 @@ export default function DataEntryTable( const addNewWord = useCallback( async ( wordToAdd: Word, - audioURLs: string[], + audio: Pronunciation[], insertIndex?: number ): Promise => { wordToAdd.note.language = analysisLang.bcp47; @@ -649,11 +660,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); } @@ -666,11 +677,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 }); @@ -706,7 +717,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. */ @@ -728,15 +739,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; } @@ -749,7 +760,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; }; @@ -927,7 +938,7 @@ export default function DataEntryTable( addNewEntry={addNewEntry} resetNewEntry={resetNewEntry} updateWordWithNewGloss={updateWordWithNewEntry} - newAudioUrls={state.newAudioUrls} + newAudio={state.newAudio} addNewAudioUrl={addNewAudioUrl} delNewAudioUrl={delNewAudioUrl} newGloss={state.newGloss} diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index cdfe4ada23..08dd018055 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -22,7 +22,7 @@ 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"); @@ -80,7 +80,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/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index 0359e6a97c..e9ee2b71b1 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -1,13 +1,14 @@ 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"; interface PronunciationsBackendProps { + audio: Pronunciation[]; playerOnly?: boolean; overrideMemo?: boolean; - pronunciationFiles: string[]; wordId: string; deleteAudio: (fileName: string) => void; uploadAudio?: (audioFile: File) => void; @@ -24,16 +25,14 @@ 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) => ( + + )); return ( <> @@ -56,8 +55,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 ef4076e59f..d88f531f43 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -1,10 +1,11 @@ import { ReactElement } from "react"; +import { Pronunciation } from "api/models"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; interface PronunciationFrontendProps { - pronunciationFiles: string[]; + audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; uploadAudio: (audioFile: File) => void; @@ -15,17 +16,15 @@ interface PronunciationFrontendProps { export default function PronunciationsFrontend( props: PronunciationFrontendProps ): ReactElement { - const audioButtons: ReactElement[] = props.pronunciationFiles.map( - (fileName) => ( - - ) - ); + const audioButtons: ReactElement[] = props.audio.map((a) => ( + + )); return ( <> diff --git a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx index 22e64fcaab..f0538e7be8 100644 --- a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -10,10 +10,11 @@ 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"; +import { newPronunciation } from "types/word"; // Test variables let testRenderer: ReactTestRenderer; -const mockAudio = ["a.wav", "b.wav"]; +const mockAudio = ["a.wav", "b.wav"].map((f) => newPronunciation(f)); const mockStore = configureMockStore()({ pronunciationsState }); const renderPronunciationsBackend = async ( @@ -25,8 +26,8 @@ const renderPronunciationsBackend = async ( { 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/WordCard/index.tsx b/src/components/WordCard/index.tsx index 3131234f9c..675e178adf 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -79,9 +79,9 @@ export default function WordCard(props: WordCardProps): ReactElement { <> {audio.length > 0 && ( {}} playerOnly - pronunciationFiles={audio} wordId={id} /> )} 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 dee559c3df..719d1852fe 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -169,11 +169,11 @@ export function updateFrontierWord( editSource.id = (await backend.updateWord(editWord)).id; // Add/remove audio. - for (const url of addAudio) { - editSource.id = await uploadFileFromUrl(editSource.id, url); + for (const audio of addAudio) { + editSource.id = await uploadFileFromUrl(editSource.id, audio.fileName); } - for (const fileName of delAudio) { - editSource.id = await backend.deleteAudio(editSource.id, fileName); + for (const audio of delAudio) { + editSource.id = await backend.deleteAudio(editSource.id, audio.fileName); } editSource.audio = (await backend.getWord(editSource.id)).audio; diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx index 868e88cd48..e0a174140b 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx @@ -21,6 +21,7 @@ import { ReviewEntriesWord, ReviewEntriesWordField, } from "goals/ReviewEntries/ReviewEntriesTypes"; +import { newPronunciation } from "types/word"; import { compareFlags } from "utilities/wordUtilities"; export class ColumnTitle { @@ -357,10 +358,7 @@ const columns: Column[] = [ field: ReviewEntriesWordField.Pronunciations, filterPlaceholder: "#", render: (rowData: ReviewEntriesWord) => ( - + ), editComponent: (props: FieldParameterStandard) => ( [] = [ ...props.rowData, audioNew: [ ...(props.rowData.audioNew ?? []), - URL.createObjectURL(file), + newPronunciation(URL.createObjectURL(file)), ], }); }, @@ -379,19 +377,23 @@ 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 + ), }); }, 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 + ), }); }, }} - 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..3dfd881283 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx @@ -1,5 +1,6 @@ import { ReactElement } from "react"; +import { Pronunciation } from "api/models"; import PronunciationsBackend from "components/Pronunciations/PronunciationsBackend"; import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; import { @@ -14,8 +15,8 @@ interface PronunciationsCellProps { delNewAudio: (url: string) => void; delOldAudio: (fileName: string) => void; }; - pronunciationFiles: string[]; - pronunciationsNew?: string[]; + audio: Pronunciation[]; + audioNew?: Pronunciation[]; wordId: string; } @@ -34,20 +35,20 @@ export default function PronunciationsCell( } - pronunciationFiles={props.pronunciationsNew ?? []} + audio={props.audioNew ?? []} deleteAudio={delNewAudio!} uploadAudio={addNewAudio!} /> ) : ( ({ @@ -40,17 +42,17 @@ const mockAudioFunctions = { // 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( @@ -66,7 +68,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); @@ -75,7 +77,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(); }); @@ -98,8 +100,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( @@ -110,7 +112,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 4b660d7575..1c3db62ff2 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, @@ -53,8 +54,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/types/word.ts b/src/types/word.ts index c82dd30191..07328f8dd4 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,10 @@ import { } from "api/models"; import { randomIntString } from "utilities/utilities"; +export function newPronunciation(fileName = "", speakerId = ""): Pronunciation { + return { fileName, speakerId }; +} + export function newDefinition(text = "", language = ""): Definition { return { text, language }; } From 775cba4c9db7bcd8fb327f0037eb71eb4d7c8148 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 Dec 2023 10:31:09 -0500 Subject: [PATCH 18/56] Export audio speaker in pronunciation label --- .../Controllers/LiftControllerTests.cs | 4 +- Backend.Tests/Services/LiftServiceTests.cs | 4 +- Backend/Services/LiftService.cs | 56 ++++++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 55ff1bd646..84e379a976 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); 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/Services/LiftService.cs b/Backend/Services/LiftService.cs index 2c25577e81..d8116db042 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -39,7 +39,31 @@ 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's data aside from the href, add as a label + if (forms.Count > 1) + { + Writer.WriteStartElement("label"); + foreach (var form in phonetic.Forms) + { + if (form.WritingSystemId != "href") + { + Writer.WriteStartElement("form"); + Writer.WriteAttributeString("lang", form.WritingSystemId); + Writer.WriteElementString("text", form.Form); + Writer.WriteEndElement(); + } + } + Writer.WriteEndElement(); + } + Writer.WriteEndElement(); Writer.WriteEndElement(); } @@ -92,6 +116,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 +124,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) { @@ -270,6 +296,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. + 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 +326,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,7 +339,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.AddDeletedEntry(entry); } @@ -503,9 +532,10 @@ 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, IList pronunciations, string path, + string projectId, List projectSpeakers) { - foreach (var audio in wordEntry.Audio) + foreach (var audio in pronunciations) { var lexPhonetic = new LexPhonetic(); var src = FileStorage.GenerateAudioFilePath(projectId, audio.FileName); @@ -522,8 +552,18 @@ 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 (!string.IsNullOrEmpty(audio.SpeakerId)) + { + var speaker = projectSpeakers.Find(s => s.Id == audio.SpeakerId); + if (speaker is not null) + { + // Use non-real language tags to avoid overwriting existing labels on FLEx import + var text = new LiftMultiText { { "speakerName", speaker.Name }, { "speakerId", speaker.Id } }; + lexPhonetic.MergeIn(MultiText.Create(text)); + } + } entry.Pronunciations.Add(lexPhonetic); } } From f8d5c828215bf631c49462bca9b004ef5ff862c7 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 Dec 2023 14:46:57 -0500 Subject: [PATCH 19/56] Protect audio with English label to prevent overwriting --- Backend.Tests/Models/WordTests.cs | 33 +++++++++++++++++++ Backend/Models/Word.cs | 13 ++++++-- Backend/Services/LiftService.cs | 33 ++++++++----------- src/api/models/pronunciation.ts | 6 ++++ .../DataEntry/DataEntryTable/index.tsx | 5 ++- .../Pronunciations/AudioRecorder.tsx | 9 +++-- .../Pronunciations/PronunciationsBackend.tsx | 2 +- .../Pronunciations/PronunciationsFrontend.tsx | 2 +- src/types/word.ts | 2 +- 9 files changed, 77 insertions(+), 28 deletions(-) diff --git a/Backend.Tests/Models/WordTests.cs b/Backend.Tests/Models/WordTests.cs index b1bf61cac1..a450f31345 100644 --- a/Backend.Tests/Models/WordTests.cs +++ b/Backend.Tests/Models/WordTests.cs @@ -165,6 +165,39 @@ public void TestAppendContainedWordContents() } } + 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/Models/Word.cs b/Backend/Models/Word.cs index 5e37ac9728..1b44310dbc 100644 --- a/Backend/Models/Word.cs +++ b/Backend/Models/Word.cs @@ -264,10 +264,15 @@ public class Pronunciation [Required] public string SpeakerId { get; set; } + /// For any with "en" label that was present on import, to prevent overwriting. + [Required] + public bool Protected { get; set; } + public Pronunciation() { FileName = ""; SpeakerId = ""; + Protected = false; } public Pronunciation(string fileName) : this() @@ -285,7 +290,8 @@ public Pronunciation Clone() return new Pronunciation { FileName = FileName, - SpeakerId = SpeakerId + SpeakerId = SpeakerId, + Protected = Protected }; } @@ -297,12 +303,13 @@ public override bool Equals(object? obj) } return FileName.Equals(other.FileName, StringComparison.Ordinal) && - SpeakerId.Equals(other.SpeakerId, StringComparison.Ordinal); + SpeakerId.Equals(other.SpeakerId, StringComparison.Ordinal) && + Protected == other.Protected; } public override int GetHashCode() { - return HashCode.Combine(FileName, SpeakerId); + return HashCode.Combine(FileName, SpeakerId, Protected); } } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index d8116db042..e3835af517 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -46,21 +46,15 @@ protected override void InsertPronunciationIfNeeded( continue; } Writer.WriteAttributeString("href", Path.GetFileName(href.Form)); - - // If there's data aside from the href, add as a label - if (forms.Count > 1) + // 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"); - foreach (var form in phonetic.Forms) - { - if (form.WritingSystemId != "href") - { - Writer.WriteStartElement("form"); - Writer.WriteAttributeString("lang", form.WritingSystemId); - Writer.WriteElementString("text", form.Form); - Writer.WriteEndElement(); - } - } + Writer.WriteStartElement("form"); + Writer.WriteAttributeString("lang", label.WritingSystemId); + Writer.WriteElementString("text", label.Form); + Writer.WriteEndElement(); Writer.WriteEndElement(); } @@ -554,13 +548,12 @@ private static async Task AddAudio(LexEntry entry, IList pronunci lexPhonetic.MergeIn(MultiText.Create(new LiftMultiText { { "href", dest } })); // If audio has speaker, include speaker info as a pronunciation label - if (!string.IsNullOrEmpty(audio.SpeakerId)) + if (!audio.Protected && !string.IsNullOrEmpty(audio.SpeakerId)) { var speaker = projectSpeakers.Find(s => s.Id == audio.SpeakerId); if (speaker is not null) { - // Use non-real language tags to avoid overwriting existing labels on FLEx import - var text = new LiftMultiText { { "speakerName", speaker.Name }, { "speakerId", speaker.Id } }; + var text = new LiftMultiText { { "en", $"Speaker #{speaker.Id}: {speaker.Name}" } }; lexPhonetic.MergeIn(MultiText.Create(text)); } } @@ -848,8 +841,10 @@ public void FinishEntry(LiftEntry entry) { // 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(new Pronunciation(audioFile)); + var media = pro.Media.First(); + var hasEnLabel = !string.IsNullOrWhiteSpace(media.Label["en"]?.Text); + var audio = new Pronunciation(media.Url) { Protected = hasEnLabel }; + newWord.Audio.Add(audio); } } @@ -942,7 +937,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/src/api/models/pronunciation.ts b/src/api/models/pronunciation.ts index 02de2a54e6..fe1ca16f7f 100644 --- a/src/api/models/pronunciation.ts +++ b/src/api/models/pronunciation.ts @@ -30,4 +30,10 @@ export interface Pronunciation { * @memberof Pronunciation */ speakerId: string; + /** + * + * @type {boolean} + * @memberof Pronunciation + */ + _protected: boolean; } diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index 82e8f750cb..4ebb9c7282 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -208,6 +208,9 @@ export default function DataEntryTable( state.currentProjectState.project.analysisWritingSystems[0] ?? defaultWritingSystem ); + const speakerId = useAppSelector( + (state: StoreState) => state.currentProjectState.speaker?.id + ); const suggestVerns = useAppSelector( (state: StoreState) => state.currentProjectState.project.autocompleteSetting === @@ -374,7 +377,7 @@ export default function DataEntryTable( const addNewAudioUrl = (file: File): void => { setState((prevState) => { const newAudio = [...prevState.newAudio]; - newAudio.push(newPronunciation(URL.createObjectURL(file))); + newAudio.push(newPronunciation(URL.createObjectURL(file), speakerId)); return { ...prevState, newAudio }; }); }; diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index a36850b830..4383fcb553 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -6,15 +6,20 @@ 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"; interface RecorderProps { id: string; - uploadAudio: (audioFile: File) => void; + uploadAudio: (audioFile: File, speakerId?: string) => void; onClick?: () => void; } export default function AudioRecorder(props: RecorderProps): ReactElement { const recorder = useContext(RecorderContext); + const speakerId = useAppSelector( + (state: StoreState) => state.currentProjectState.speaker?.id + ); const { t } = useTranslation(); function startRecording(): void { @@ -35,7 +40,7 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { lastModified: Date.now(), type: Recorder.blobType, }; - props.uploadAudio(new File([blob], fileName, options)); + props.uploadAudio(new File([blob], fileName, options), speakerId); } return ( diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index e9ee2b71b1..53ad501a01 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -11,7 +11,7 @@ interface PronunciationsBackendProps { overrideMemo?: boolean; wordId: string; deleteAudio: (fileName: string) => void; - uploadAudio?: (audioFile: File) => void; + uploadAudio?: (audioFile: File, speakerId?: string) => void; } /** Audio recording/playing component for backend audio. */ diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index d88f531f43..c078078208 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -8,7 +8,7 @@ interface PronunciationFrontendProps { audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; - uploadAudio: (audioFile: File) => void; + uploadAudio: (audioFile: File, speakerId?: string) => void; onClick?: () => void; } diff --git a/src/types/word.ts b/src/types/word.ts index 07328f8dd4..a8a7956934 100644 --- a/src/types/word.ts +++ b/src/types/word.ts @@ -16,7 +16,7 @@ import { import { randomIntString } from "utilities/utilities"; export function newPronunciation(fileName = "", speakerId = ""): Pronunciation { - return { fileName, speakerId }; + return { fileName, speakerId, _protected: false }; } export function newDefinition(text = "", language = ""): Definition { From 6c30b7df156f4979e2006ec9698809136f0df5dd Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 Dec 2023 16:43:20 -0500 Subject: [PATCH 20/56] Add null check to fix tests --- Backend/Services/LiftService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index e3835af517..38c514106c 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -842,8 +842,8 @@ public void FinishEntry(LiftEntry entry) // get path to audio file in lift package at // ~/{projectId}/Import/ExtractedLocation/Lift/audio/{audioFile}.mp3 var media = pro.Media.First(); - var hasEnLabel = !string.IsNullOrWhiteSpace(media.Label["en"]?.Text); - var audio = new Pronunciation(media.Url) { Protected = hasEnLabel }; + var hasLabel = media.Label is not null && !string.IsNullOrWhiteSpace(media.Label["en"]?.Text); + var audio = new Pronunciation(media.Url) { Protected = hasLabel }; newWord.Audio.Add(audio); } } From 969e96f70260b5b47914e9c1c86418bd90529474 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 Dec 2023 17:10:03 -0500 Subject: [PATCH 21/56] Fix frontend tests --- .../tests/DeleteButtonWithDialog.test.tsx | 85 +++++++++++++++++++ .../CellComponents/DeleteCell.tsx | 12 +-- .../CellComponents/tests/DeleteCell.test.tsx | 55 ++---------- .../tests/PronunciationsCell.test.tsx | 8 +- 4 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 src/components/Buttons/tests/DeleteButtonWithDialog.test.tsx diff --git a/src/components/Buttons/tests/DeleteButtonWithDialog.test.tsx b/src/components/Buttons/tests/DeleteButtonWithDialog.test.tsx new file mode 100644 index 0000000000..07213d5706 --- /dev/null +++ b/src/components/Buttons/tests/DeleteButtonWithDialog.test.tsx @@ -0,0 +1,85 @@ +import { ReactTestRenderer, act, create } from "react-test-renderer"; + +import "tests/reactI18nextMock"; + +import DeleteButtonWithDialog from "components/Buttons/DeleteButtonWithDialog"; +import { CancelConfirmDialog } from "components/Dialogs"; + +// Dialog uses portals, which are not supported in react-test-renderer. +jest.mock("@mui/material", () => { + const materialUiCore = jest.requireActual("@mui/material"); + return { + ...jest.requireActual("@mui/material"), + Dialog: materialUiCore.Container, + }; +}); + +const mockDelete = jest.fn(); +const buttonId = "button-id"; +const buttonIdCancel = "button-id-cancel"; +const buttonIdConfirm = "button-id-confirm"; +const textId = "text-id"; + +let cellHandle: ReactTestRenderer; + +const renderDeleteCell = async (): Promise => { + await act(async () => { + cellHandle = create( + + ); + }); +}; + +beforeEach(async () => { + jest.clearAllMocks(); + await renderDeleteCell(); +}); + +describe("DeleteCell", () => { + it("has working dialog buttons", async () => { + const dialog = cellHandle.root.findByType(CancelConfirmDialog); + const deleteButton = cellHandle.root.findByProps({ id: buttonId }); + + expect(dialog.props.open).toBeFalsy(); + await act(async () => { + deleteButton.props.onClick(); + }); + expect(dialog.props.open).toBeTruthy(); + const cancelButton = cellHandle.root.findByProps({ id: buttonIdCancel }); + await act(async () => { + cancelButton.props.onClick(); + }); + expect(dialog.props.open).toBeFalsy(); + await act(async () => { + deleteButton.props.onClick(); + }); + expect(dialog.props.open).toBeTruthy(); + const confButton = cellHandle.root.findByProps({ id: buttonIdConfirm }); + await act(async () => { + await confButton.props.onClick(); + }); + expect(dialog.props.open).toBeFalsy(); + }); + + it("only deletes after confirmation", async () => { + const deleteButton = cellHandle.root.findByProps({ id: buttonId }); + + await act(async () => { + deleteButton.props.onClick(); + cellHandle.root.findByProps({ id: buttonIdCancel }).props.onClick(); + deleteButton.props.onClick(); + }); + expect(mockDelete).not.toHaveBeenCalled(); + const confButton = cellHandle.root.findByProps({ id: buttonIdConfirm }); + await act(async () => { + await confButton.props.onClick(); + }); + expect(mockDelete).toHaveBeenCalled(); + }); +}); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx index f784bc41cb..c360fac5dc 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/DeleteCell.tsx @@ -7,9 +7,9 @@ import { ReviewEntriesWord } from "goals/ReviewEntries/ReviewEntriesTypes"; import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; -export const buttonId = (wordId: string): string => `row-${wordId}-delete`; -export const buttonIdCancel = "delete-cancel"; -export const buttonIdConfirm = "delete-confirm"; +const buttonId = (wordId: string): string => `row-${wordId}-delete`; +const buttonIdCancel = "delete-cancel"; +const buttonIdConfirm = "delete-confirm"; interface DeleteCellProps { rowData: ReviewEntriesWord; @@ -32,9 +32,9 @@ export default function DeleteCell(props: DeleteCellProps): ReactElement { return ( { }); jest.mock("backend", () => ({ - deleteFrontierWord: () => mockDeleteFrontierWord(), + deleteFrontierWord: () => jest.fn(), })); jest.mock("types/hooks", () => { return { @@ -35,11 +31,8 @@ jest.mock("types/hooks", () => { }; }); -const mockDeleteFrontierWord = jest.fn(); - const mockStore = configureMockStore()({ reviewEntriesState }); const mockWord = mockWords()[0]; -const buttonIdDelete = buttonId(mockWord.id); let cellHandle: ReactTestRenderer; @@ -59,45 +52,7 @@ beforeEach(async () => { }); describe("DeleteCell", () => { - it("has working dialog buttons", async () => { - const dialog = cellHandle.root.findByType(CancelConfirmDialog); - const deleteButton = cellHandle.root.findByProps({ id: buttonIdDelete }); - const cancelButton = cellHandle.root.findByProps({ id: buttonIdCancel }); - const confButton = cellHandle.root.findByProps({ id: buttonIdConfirm }); - - expect(dialog.props.open).toBeFalsy(); - await act(async () => { - deleteButton.props.onClick(); - }); - expect(dialog.props.open).toBeTruthy(); - await act(async () => { - cancelButton.props.onClick(); - }); - expect(dialog.props.open).toBeFalsy(); - await act(async () => { - deleteButton.props.onClick(); - }); - expect(dialog.props.open).toBeTruthy(); - await act(async () => { - await confButton.props.onClick(); - }); - expect(dialog.props.open).toBeFalsy(); - }); - - it("only deletes after confirmation", async () => { - const deleteButton = cellHandle.root.findByProps({ id: buttonIdDelete }); - const cancelButton = cellHandle.root.findByProps({ id: buttonIdCancel }); - const confButton = cellHandle.root.findByProps({ id: buttonIdConfirm }); - - await act(async () => { - deleteButton.props.onClick(); - cancelButton.props.onClick(); - deleteButton.props.onClick(); - }); - expect(mockDeleteFrontierWord).not.toBeCalled(); - await act(async () => { - await confButton.props.onClick(); - }); - expect(mockDeleteFrontierWord).toBeCalled(); + it("renders", () => { + cellHandle.root.findByType(DeleteButtonWithDialog); }); }); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx index f4f03c9841..61c9fe0c26 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx @@ -6,10 +6,12 @@ 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"; import { newPronunciation } from "types/word"; @@ -27,7 +29,11 @@ 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(); From 144751dd54b1cd5e4420b022cecb67f7243bda0d Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 4 Dec 2023 17:54:34 -0500 Subject: [PATCH 22/56] Attach current speaker id to new audio recordings --- Backend/Controllers/AudioController.cs | 7 ++-- src/api/api/audio-api.ts | 32 +++++++++++++++++-- src/backend/index.ts | 5 +-- .../DataEntry/DataEntryTable/index.tsx | 6 ++-- .../Pronunciations/AudioRecorder.tsx | 9 ++---- .../Pronunciations/PronunciationsBackend.tsx | 2 +- .../Pronunciations/PronunciationsFrontend.tsx | 2 +- src/components/Pronunciations/utilities.ts | 5 +-- .../Redux/ReviewEntriesActions.ts | 11 +++++-- .../ReviewEntriesTable/CellColumns.tsx | 4 +-- .../CellComponents/PronunciationsCell.tsx | 12 ++++--- 11 files changed, 64 insertions(+), 31 deletions(-) diff --git a/Backend/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index bb6e93ddb5..4f1aa29f07 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -65,9 +65,9 @@ public IActionResult DownloadAudioFile(string projectId, string wordId, string f /// locally to ~/.CombineFiles/{ProjectId}/Import/ExtractedLocation/Lift/audio ///
/// Id of updated word - [HttpPost("upload", Name = "UploadAudioFile")] + [HttpPost("upload/{speakerId}", Name = "UploadAudioFile")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(string))] - public async Task UploadAudioFile(string projectId, string wordId, + public async Task UploadAudioFile(string projectId, string wordId, string speakerId, [FromForm] FileUpload fileUpload) { if (!await _permissionService.HasProjectPermission(HttpContext, Permission.WordEntry, projectId)) @@ -115,7 +115,8 @@ public async Task UploadAudioFile(string projectId, string wordId { return NotFound(wordId); } - word.Audio.Add(new Pronunciation(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/src/api/api/audio-api.ts b/src/api/api/audio-api.ts index 551e0eec7b..5bfd51ca77 100644 --- a/src/api/api/audio-api.ts +++ b/src/api/api/audio-api.ts @@ -156,6 +156,7 @@ export const AudioApiAxiosParamCreator = function ( * * @param {string} projectId * @param {string} wordId + * @param {string} speakerId * @param {any} file * @param {string} name * @param {string} filePath @@ -165,6 +166,7 @@ export const AudioApiAxiosParamCreator = function ( uploadAudioFile: async ( projectId: string, wordId: string, + speakerId: string, file: any, name: string, filePath: string, @@ -174,6 +176,8 @@ export const AudioApiAxiosParamCreator = function ( assertParamExists("uploadAudioFile", "projectId", projectId); // verify required parameter 'wordId' is not null or undefined assertParamExists("uploadAudioFile", "wordId", wordId); + // verify required parameter 'speakerId' is not null or undefined + assertParamExists("uploadAudioFile", "speakerId", speakerId); // verify required parameter 'file' is not null or undefined assertParamExists("uploadAudioFile", "file", file); // verify required parameter 'name' is not null or undefined @@ -181,9 +185,10 @@ export const AudioApiAxiosParamCreator = function ( // verify required parameter 'filePath' is not null or undefined assertParamExists("uploadAudioFile", "filePath", filePath); const localVarPath = - `/v1/projects/{projectId}/words/{wordId}/audio/upload` + `/v1/projects/{projectId}/words/{wordId}/audio/upload/{speakerId}` .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) - .replace(`{${"wordId"}}`, encodeURIComponent(String(wordId))); + .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; @@ -304,6 +309,7 @@ export const AudioApiFp = function (configuration?: Configuration) { * * @param {string} projectId * @param {string} wordId + * @param {string} speakerId * @param {any} file * @param {string} name * @param {string} filePath @@ -313,6 +319,7 @@ export const AudioApiFp = function (configuration?: Configuration) { async uploadAudioFile( projectId: string, wordId: string, + speakerId: string, file: any, name: string, filePath: string, @@ -323,6 +330,7 @@ export const AudioApiFp = function (configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.uploadAudioFile( projectId, wordId, + speakerId, file, name, filePath, @@ -389,6 +397,7 @@ export const AudioApiFactory = function ( * * @param {string} projectId * @param {string} wordId + * @param {string} speakerId * @param {any} file * @param {string} name * @param {string} filePath @@ -398,13 +407,22 @@ export const AudioApiFactory = function ( uploadAudioFile( projectId: string, wordId: string, + speakerId: string, file: any, name: string, filePath: string, options?: any ): AxiosPromise { return localVarFp - .uploadAudioFile(projectId, wordId, file, name, filePath, options) + .uploadAudioFile( + projectId, + wordId, + speakerId, + file, + name, + filePath, + options + ) .then((request) => request(axios, basePath)); }, }; @@ -486,6 +504,13 @@ export interface AudioApiUploadAudioFileRequest { */ readonly wordId: string; + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFile + */ + readonly speakerId: string; + /** * * @type {any} @@ -572,6 +597,7 @@ export class AudioApi extends BaseAPI { .uploadAudioFile( requestParameters.projectId, requestParameters.wordId, + requestParameters.speakerId, requestParameters.file, requestParameters.name, requestParameters.filePath, diff --git a/src/backend/index.ts b/src/backend/index.ts index dbc878f12f..8e348501cd 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -124,11 +124,12 @@ function defaultOptions(): object { export async function uploadAudio( wordId: string, - audioFile: File + audioFile: File, + speakerId = "" ): Promise { const projectId = LocalStorage.getProjectId(); const resp = await audioApi.uploadAudioFile( - { projectId, wordId, ...fileUpload(audioFile) }, + { projectId, speakerId, wordId, ...fileUpload(audioFile) }, { headers: { ...authHeader(), "content-type": "application/json" } } ); return resp.data; diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index 8c85505eec..a7c4b5f576 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -571,7 +571,7 @@ export default function DataEntryTable( defunctWord(oldId); let newId = oldId; for (const a of audio) { - newId = await uploadFileFromUrl(newId, a.fileName); + newId = await uploadFileFromUrl(newId, a.fileName, a.speakerId); } defunctWord(oldId, newId); return newId; @@ -583,10 +583,10 @@ export default function DataEntryTable( const addAudioFileToWord = useCallback( async (oldId: string, audioFile: File): Promise => { defunctWord(oldId); - const newId = await backend.uploadAudio(oldId, audioFile); + const newId = await backend.uploadAudio(oldId, audioFile, speakerId); defunctWord(oldId, newId); }, - [defunctWord] + [defunctWord, speakerId] ); /** Add a word determined to be a duplicate. diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index 4383fcb553..a36850b830 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -6,20 +6,15 @@ 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"; interface RecorderProps { id: string; - uploadAudio: (audioFile: File, speakerId?: string) => void; + uploadAudio: (audioFile: File) => void; onClick?: () => void; } export default function AudioRecorder(props: RecorderProps): ReactElement { const recorder = useContext(RecorderContext); - const speakerId = useAppSelector( - (state: StoreState) => state.currentProjectState.speaker?.id - ); const { t } = useTranslation(); function startRecording(): void { @@ -40,7 +35,7 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { lastModified: Date.now(), type: Recorder.blobType, }; - props.uploadAudio(new File([blob], fileName, options), speakerId); + props.uploadAudio(new File([blob], fileName, options)); } return ( diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index 53ad501a01..e9ee2b71b1 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -11,7 +11,7 @@ interface PronunciationsBackendProps { overrideMemo?: boolean; wordId: string; deleteAudio: (fileName: string) => void; - uploadAudio?: (audioFile: File, speakerId?: string) => void; + uploadAudio?: (audioFile: File) => void; } /** Audio recording/playing component for backend audio. */ diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index c078078208..d88f531f43 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -8,7 +8,7 @@ interface PronunciationFrontendProps { audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; - uploadAudio: (audioFile: File, speakerId?: string) => void; + uploadAudio: (audioFile: File) => void; onClick?: () => void; } diff --git a/src/components/Pronunciations/utilities.ts b/src/components/Pronunciations/utilities.ts index 08761c38a0..fd9e2eae78 100644 --- a/src/components/Pronunciations/utilities.ts +++ b/src/components/Pronunciations/utilities.ts @@ -14,7 +14,8 @@ export function getFileNameForWord(wordId: string): string { * Return the id of the updated word. */ export async function uploadFileFromUrl( wordId: string, - url: string + url: string, + speakerId = "" ): Promise { const audioBlob = await fetch(url).then((result) => result.blob()); const fileName = getFileNameForWord(wordId); @@ -22,7 +23,7 @@ export async function uploadFileFromUrl( type: audioBlob.type, lastModified: Date.now(), }); - const newId = await uploadAudio(wordId, audioFile); + const newId = await uploadAudio(wordId, audioFile, speakerId); URL.revokeObjectURL(url); return newId; } diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index 719d1852fe..a387e1bb5b 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -170,7 +170,11 @@ export function updateFrontierWord( // Add/remove audio. for (const audio of addAudio) { - editSource.id = await uploadFileFromUrl(editSource.id, audio.fileName); + editSource.id = await uploadFileFromUrl( + editSource.id, + audio.fileName, + audio.speakerId + ); } for (const audio of delAudio) { editSource.id = await backend.deleteAudio(editSource.id, audio.fileName); @@ -222,9 +226,10 @@ export function deleteAudio( export function uploadAudio( wordId: string, - audioFile: File + audioFile: File, + speakerId = "" ): (dispatch: StoreStateDispatch) => Promise { return refreshWord(wordId, (wordId: string) => - backend.uploadAudio(wordId, audioFile) + backend.uploadAudio(wordId, audioFile, speakerId) ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx index 32e0a74b0b..f994ae3e52 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx @@ -363,13 +363,13 @@ const columns: Column[] = [ editComponent: (props: FieldParameterStandard) => ( { + addNewAudio: (file: File, speakerId?: string): void => { props.onRowDataChange && props.onRowDataChange({ ...props.rowData, audioNew: [ ...(props.rowData.audioNew ?? []), - newPronunciation(URL.createObjectURL(file)), + newPronunciation(URL.createObjectURL(file), speakerId), ], }); }, diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx index 3dfd881283..b1d3cc18bf 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx @@ -7,11 +7,12 @@ import { deleteAudio, uploadAudio, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { useAppDispatch } from "types/hooks"; +import { StoreState } from "types"; +import { useAppDispatch, useAppSelector } from "types/hooks"; interface PronunciationsCellProps { audioFunctions?: { - addNewAudio: (file: File) => void; + addNewAudio: (file: File, speakerId?: string) => void; delNewAudio: (url: string) => void; delOldAudio: (fileName: string) => void; }; @@ -24,10 +25,13 @@ export default function PronunciationsCell( props: PronunciationsCellProps ): ReactElement { const dispatch = useAppDispatch(); + const speakerId = useAppSelector( + (state: StoreState) => state.currentProjectState.speaker?.id + ); const dispatchDelete = (fileName: string): Promise => dispatch(deleteAudio(props.wordId, fileName)); const dispatchUpload = (audioFile: File): Promise => - dispatch(uploadAudio(props.wordId, audioFile)); + dispatch(uploadAudio(props.wordId, audioFile, speakerId)); const { addNewAudio, delNewAudio, delOldAudio } = props.audioFunctions ?? {}; @@ -44,7 +48,7 @@ export default function PronunciationsCell( } audio={props.audioNew ?? []} deleteAudio={delNewAudio!} - uploadAudio={addNewAudio!} + uploadAudio={(file: File) => addNewAudio!(file, speakerId)} /> ) : ( Date: Mon, 4 Dec 2023 17:57:30 -0500 Subject: [PATCH 23/56] Fix backend test --- Backend.Tests/Controllers/AudioControllerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 401632f1d5..1cd22a03b0 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -87,7 +87,7 @@ public void TestAudioImport() var word = _wordRepo.Create(Util.RandomWord(_projId)).Result; // `fileUpload` contains the file stream and the name of the file. - _ = _audioController.UploadAudioFile(_projId, word.Id, fileUpload).Result; + _ = _audioController.UploadAudioFile(_projId, word.Id, "", fileUpload).Result; var foundWord = _wordRepo.GetWord(_projId, word.Id).Result; Assert.That(foundWord?.Audio, Is.Not.Null); From a8fccd61a22ab1ffbbc382a0e0c0ec45f11d81cb Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 5 Dec 2023 11:28:48 -0500 Subject: [PATCH 24/56] Implement FileWithSpeakerId extends File --- src/backend/index.ts | 7 ++++--- .../DataEntry/DataEntryTable/NewEntry/index.tsx | 3 ++- .../DataEntry/DataEntryTable/RecentEntry.tsx | 8 ++++---- .../DataEntry/DataEntryTable/index.tsx | 16 ++++++++-------- .../DataEntryTable/tests/RecentEntry.test.tsx | 4 ++-- src/components/Dialogs/RecordAudioDialog.tsx | 7 +++++-- src/components/Pronunciations/AudioRecorder.tsx | 11 +++++++++-- .../Pronunciations/PronunciationsBackend.tsx | 3 ++- .../Pronunciations/PronunciationsFrontend.tsx | 3 ++- .../Pronunciations/tests/AudioRecorder.test.tsx | 12 +++++------- .../tests/PronunciationsBackend.test.tsx | 4 ++-- .../tests/PronunciationsFrontend.test.tsx | 4 ++-- src/components/Pronunciations/utilities.ts | 4 ++-- .../ReviewEntries/Redux/ReviewEntriesActions.ts | 7 +++---- .../ReviewEntriesTable/CellColumns.tsx | 6 +++--- .../CellComponents/PronunciationsCell.tsx | 15 ++++++--------- src/types/word.ts | 4 ++++ 17 files changed, 65 insertions(+), 53 deletions(-) diff --git a/src/backend/index.ts b/src/backend/index.ts index 8e348501cd..194967da89 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -31,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"; @@ -124,12 +125,12 @@ function defaultOptions(): object { export async function uploadAudio( wordId: string, - audioFile: File, - speakerId = "" + file: FileWithSpeakerId ): Promise { const projectId = LocalStorage.getProjectId(); + const speakerId = file.speakerId ?? ""; const resp = await audioApi.uploadAudioFile( - { projectId, speakerId, wordId, ...fileUpload(audioFile) }, + { projectId, speakerId, wordId, ...fileUpload(file) }, { headers: { ...authHeader(), "content-type": "application/json" } } ); return resp.data; diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index 9bdb347edb..9e770c611b 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx @@ -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"; @@ -46,7 +47,7 @@ interface NewEntryProps { resetNewEntry: () => void; updateWordWithNewGloss: (wordId: string) => Promise; newAudio: Pronunciation[]; - addNewAudioUrl: (file: File) => void; + addNewAudioUrl: (file: FileWithSpeakerId) => void; delNewAudioUrl: (url: string) => void; newGloss: string; setNewGloss: (gloss: string) => void; diff --git a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx index 65529941fb..2cd40fe781 100644 --- a/src/components/DataEntry/DataEntryTable/RecentEntry.tsx +++ b/src/components/DataEntry/DataEntryTable/RecentEntry.tsx @@ -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,7 +23,7 @@ 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; + addAudioToWord: (wordId: string, file: FileWithSpeakerId) => void; deleteAudioFromWord: (wordId: string, fileName: string) => void; focusNewEntry: () => void; analysisLang: WritingSystem; @@ -139,8 +139,8 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { deleteAudio={(fileName: string) => { props.deleteAudioFromWord(props.entry.id, fileName); }} - uploadAudio={(audioFile: File) => { - props.addAudioToWord(props.entry.id, audioFile); + uploadAudio={(file: FileWithSpeakerId) => { + props.addAudioToWord(props.entry.id, file); }} /> )} diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index a7c4b5f576..f1a5dcf5d2 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -36,6 +36,7 @@ import { Hash } from "types/hash"; import { useAppSelector } from "types/hooks"; import theme from "types/theme"; import { + FileWithSpeakerId, newNote, newPronunciation, newSense, @@ -229,9 +230,6 @@ export default function DataEntryTable( state.currentProjectState.project.analysisWritingSystems[0] ?? defaultWritingSystem ); - const speakerId = useAppSelector( - (state: StoreState) => state.currentProjectState.speaker?.id - ); const suggestVerns = useAppSelector( (state: StoreState) => state.currentProjectState.project.autocompleteSetting === @@ -377,10 +375,12 @@ export default function DataEntryTable( }; /** Add an audio file to newAudioUrls. */ - const addNewAudioUrl = (file: File): void => { + const addNewAudioUrl = (file: FileWithSpeakerId): void => { setState((prevState) => { const newAudio = [...prevState.newAudio]; - newAudio.push(newPronunciation(URL.createObjectURL(file), speakerId)); + newAudio.push( + newPronunciation(URL.createObjectURL(file), file.speakerId) + ); return { ...prevState, newAudio }; }); }; @@ -581,12 +581,12 @@ 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, speakerId); + const newId = await backend.uploadAudio(oldId, file); defunctWord(oldId, newId); }, - [defunctWord, speakerId] + [defunctWord] ); /** Add a word determined to be a duplicate. diff --git a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx index 08dd018055..8a6288d2a0 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,14 +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 { newPronunciation, simpleWord } from "types/word"; import { newWritingSystem } from "types/writingSystem"; jest.mock("@mui/material/Autocomplete", () => "div"); -const mockStore = configureMockStore()({ pronunciationsState }); +const mockStore = configureMockStore()(defaultState); const mockVern = "Vernacular"; const mockGloss = "Gloss"; const mockWord = simpleWord(mockVern, mockGloss); diff --git a/src/components/Dialogs/RecordAudioDialog.tsx b/src/components/Dialogs/RecordAudioDialog.tsx index c9bbcdcf24..e45a498ac6 100644 --- a/src/components/Dialogs/RecordAudioDialog.tsx +++ b/src/components/Dialogs/RecordAudioDialog.tsx @@ -9,7 +9,7 @@ interface RecordAudioDialogProps { close: () => void; open: boolean; titleId: string; - uploadAudio: (audioFile: File) => Promise; + uploadAudio: (file: File) => Promise; } export default function RecordAudioDialog( @@ -21,7 +21,10 @@ export default function RecordAudioDialog( {t(props.titleId)} - + props.uploadAudio(file)} + /> ); diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index a36850b830..8cc3b71257 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -6,14 +6,20 @@ 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 { id: string; - uploadAudio: (audioFile: File) => void; + uploadAudio: (file: FileWithSpeakerId) => void; onClick?: () => void; } export default function AudioRecorder(props: RecorderProps): ReactElement { + const speakerId = useAppSelector( + (state: StoreState) => state.currentProjectState.speaker?.id + ); const recorder = useContext(RecorderContext); const { t } = useTranslation(); @@ -35,7 +41,8 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { lastModified: Date.now(), type: Recorder.blobType, }; - props.uploadAudio(new File([blob], fileName, options)); + const file = new File([blob], fileName, options); + props.uploadAudio({ ...file, speakerId }); } return ( diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index e9ee2b71b1..8f9c69b1ce 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -4,6 +4,7 @@ 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[]; @@ -11,7 +12,7 @@ interface PronunciationsBackendProps { overrideMemo?: boolean; wordId: string; deleteAudio: (fileName: string) => void; - uploadAudio?: (audioFile: File) => void; + uploadAudio?: (file: FileWithSpeakerId) => void; } /** Audio recording/playing component for backend audio. */ diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index d88f531f43..d768a52588 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -3,12 +3,13 @@ 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 { audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; - uploadAudio: (audioFile: File) => void; + uploadAudio: (file: FileWithSpeakerId) => void; onClick?: () => void; } diff --git a/src/components/Pronunciations/tests/AudioRecorder.test.tsx b/src/components/Pronunciations/tests/AudioRecorder.test.tsx index f01d3872e9..546bca627f 100644 --- a/src/components/Pronunciations/tests/AudioRecorder.test.tsx +++ b/src/components/Pronunciations/tests/AudioRecorder.test.tsx @@ -5,24 +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"; 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, @@ -92,7 +90,7 @@ 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 f0538e7be8..007f8e5a46 100644 --- a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -5,17 +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"; import { newPronunciation } from "types/word"; // Test variables let testRenderer: ReactTestRenderer; const mockAudio = ["a.wav", "b.wav"].map((f) => newPronunciation(f)); -const mockStore = configureMockStore()({ pronunciationsState }); +const mockStore = configureMockStore()(defaultState); const renderPronunciationsBackend = async ( withRecord: boolean diff --git a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx index 5b2a696e32..8f7e15827c 100644 --- a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx @@ -5,16 +5,16 @@ 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"; 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", () => { diff --git a/src/components/Pronunciations/utilities.ts b/src/components/Pronunciations/utilities.ts index fd9e2eae78..4d84aedf98 100644 --- a/src/components/Pronunciations/utilities.ts +++ b/src/components/Pronunciations/utilities.ts @@ -19,11 +19,11 @@ export async function uploadFileFromUrl( ): Promise { const audioBlob = await fetch(url).then((result) => result.blob()); const fileName = getFileNameForWord(wordId); - const audioFile = new File([audioBlob], fileName, { + const file = new File([audioBlob], fileName, { type: audioBlob.type, lastModified: Date.now(), }); - const newId = await uploadAudio(wordId, audioFile, speakerId); + const newId = await uploadAudio(wordId, { ...file, speakerId }); URL.revokeObjectURL(url); return newId; } diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index bbd2056b65..46ec2fed98 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -20,7 +20,7 @@ import { ReviewEntriesWord, } from "goals/ReviewEntries/ReviewEntriesTypes"; import { StoreStateDispatch } from "types/Redux/actions"; -import { newNote, newSense } from "types/word"; +import { FileWithSpeakerId, newNote, newSense } from "types/word"; // Action Creation Functions @@ -231,10 +231,9 @@ export function deleteAudio( export function uploadAudio( wordId: string, - audioFile: File, - speakerId = "" + file: FileWithSpeakerId ): (dispatch: StoreStateDispatch) => Promise { return asyncRefreshWord(wordId, (wordId: string) => - backend.uploadAudio(wordId, audioFile, speakerId) + backend.uploadAudio(wordId, file) ); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx index f994ae3e52..de64e638e2 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx @@ -21,7 +21,7 @@ import { ReviewEntriesWord, ReviewEntriesWordField, } from "goals/ReviewEntries/ReviewEntriesTypes"; -import { newPronunciation } from "types/word"; +import { FileWithSpeakerId, newPronunciation } from "types/word"; import { compareFlags } from "utilities/wordUtilities"; export class ColumnTitle { @@ -363,13 +363,13 @@ const columns: Column[] = [ editComponent: (props: FieldParameterStandard) => ( { + addNewAudio: (file: FileWithSpeakerId): void => { props.onRowDataChange && props.onRowDataChange({ ...props.rowData, audioNew: [ ...(props.rowData.audioNew ?? []), - newPronunciation(URL.createObjectURL(file), speakerId), + newPronunciation(URL.createObjectURL(file), file.speakerId), ], }); }, diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx index b1d3cc18bf..6b234daded 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx @@ -7,12 +7,12 @@ import { deleteAudio, uploadAudio, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; -import { StoreState } from "types"; -import { useAppDispatch, useAppSelector } from "types/hooks"; +import { useAppDispatch } from "types/hooks"; +import { FileWithSpeakerId } from "types/word"; interface PronunciationsCellProps { audioFunctions?: { - addNewAudio: (file: File, speakerId?: string) => void; + addNewAudio: (file: FileWithSpeakerId) => void; delNewAudio: (url: string) => void; delOldAudio: (fileName: string) => void; }; @@ -25,13 +25,10 @@ export default function PronunciationsCell( props: PronunciationsCellProps ): ReactElement { const dispatch = useAppDispatch(); - const speakerId = useAppSelector( - (state: StoreState) => state.currentProjectState.speaker?.id - ); const dispatchDelete = (fileName: string): Promise => dispatch(deleteAudio(props.wordId, fileName)); - const dispatchUpload = (audioFile: File): Promise => - dispatch(uploadAudio(props.wordId, audioFile, speakerId)); + const dispatchUpload = (file: FileWithSpeakerId): Promise => + dispatch(uploadAudio(props.wordId, file)); const { addNewAudio, delNewAudio, delOldAudio } = props.audioFunctions ?? {}; @@ -48,7 +45,7 @@ export default function PronunciationsCell( } audio={props.audioNew ?? []} deleteAudio={delNewAudio!} - uploadAudio={(file: File) => addNewAudio!(file, speakerId)} + uploadAudio={addNewAudio!} /> ) : ( Date: Tue, 5 Dec 2023 11:40:48 -0500 Subject: [PATCH 25/56] uploadFileFromUrl -> uploadFileFromPronunciation --- .../DataEntry/DataEntryTable/index.tsx | 4 ++-- src/components/Pronunciations/utilities.ts | 16 ++++++++-------- .../ReviewEntries/Redux/ReviewEntriesActions.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index f1a5dcf5d2..9a704d5a4d 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -30,7 +30,7 @@ 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"; @@ -571,7 +571,7 @@ export default function DataEntryTable( defunctWord(oldId); let newId = oldId; for (const a of audio) { - newId = await uploadFileFromUrl(newId, a.fileName, a.speakerId); + newId = await uploadFileFromPronunciation(newId, a); } defunctWord(oldId, newId); return newId; diff --git a/src/components/Pronunciations/utilities.ts b/src/components/Pronunciations/utilities.ts index 4d84aedf98..94a5760e37 100644 --- a/src/components/Pronunciations/utilities.ts +++ b/src/components/Pronunciations/utilities.ts @@ -1,3 +1,4 @@ +import { Pronunciation } from "api"; import { uploadAudio } from "backend"; /** Generate a timestamp-based file name for the given `wordId`. */ @@ -9,21 +10,20 @@ 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, - speakerId = "" + audio: Pronunciation ): Promise { - const audioBlob = await fetch(url).then((result) => result.blob()); - const fileName = getFileNameForWord(wordId); - const file = 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, { ...file, speakerId }); - URL.revokeObjectURL(url); + URL.revokeObjectURL(fileName); return newId; } diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index 46ec2fed98..35187bc070 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -6,7 +6,7 @@ import { addEntryEditToGoal, asyncUpdateGoal, } from "components/GoalTimeline/Redux/GoalActions"; -import { uploadFileFromUrl } from "components/Pronunciations/utilities"; +import { uploadFileFromPronunciation } from "components/Pronunciations/utilities"; import { deleteWordAction, resetReviewEntriesAction, @@ -180,7 +180,7 @@ export function updateFrontierWord( // Add/remove audio. for (const audio of addAudio) { - newId = await uploadFileFromUrl(newId, audio.fileName, audio.speakerId); + newId = await uploadFileFromPronunciation(newId, audio); } for (const audio of delAudio) { newId = await backend.deleteAudio(newId, audio.fileName); From 8b7bdae33ae2ca8009d2e8ccc9369316ae4673f2 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 5 Dec 2023 15:43:00 -0500 Subject: [PATCH 26/56] Enable changing audio speaker --- public/locales/en/translation.json | 4 ++ src/backend/index.ts | 10 +++ src/components/AppBar/SpeakerMenu.tsx | 27 +++++--- .../DataEntryTable/NewEntry/index.tsx | 15 +++-- .../NewEntry/tests/index.test.tsx | 5 +- .../DataEntry/DataEntryTable/RecentEntry.tsx | 14 ++-- .../DataEntry/DataEntryTable/index.tsx | 40 +++++++++-- .../DataEntryTable/tests/RecentEntry.test.tsx | 3 +- .../SpeakerConsentListItemIcon.tsx | 5 +- src/components/Pronunciations/AudioPlayer.tsx | 67 ++++++++++++++++--- .../Pronunciations/PronunciationsBackend.tsx | 8 ++- .../Pronunciations/PronunciationsFrontend.tsx | 7 +- .../tests/PronunciationsBackend.test.tsx | 1 + .../tests/PronunciationsFrontend.test.tsx | 1 + src/components/WordCard/index.tsx | 3 +- .../Redux/ReviewEntriesActions.ts | 18 ++++- .../ReviewEntriesTable/CellColumns.tsx | 22 +++++- .../CellComponents/PronunciationsCell.tsx | 11 ++- .../tests/PronunciationsCell.test.tsx | 4 ++ 19 files changed, 216 insertions(+), 49 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c0dcf99e4a..c7f99f7e09 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -462,6 +462,10 @@ "pronunciations": { "recordTooltip": "Press and hold to record.", "playTooltip": "Click to play; shift click to delete.", + "speaker": "Speaker: {{ val }}.", + "speakerAdd": "Right-click to add a speaker to this recording.", + "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/src/backend/index.ts b/src/backend/index.ts index 194967da89..612627c47b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -473,6 +473,16 @@ export async function getAllSpeakers(projectId?: string): Promise { 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( diff --git a/src/components/AppBar/SpeakerMenu.tsx b/src/components/AppBar/SpeakerMenu.tsx index f1ae2dff4d..098a655e47 100644 --- a/src/components/AppBar/SpeakerMenu.tsx +++ b/src/components/AppBar/SpeakerMenu.tsx @@ -30,6 +30,10 @@ 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 { @@ -67,7 +71,10 @@ export default function SpeakerMenu(): ReactElement { open={Boolean(anchorElement)} transformOrigin={{ horizontal: "right", vertical: "top" }} > - + dispatch(setCurrentSpeaker(speaker))} + selectedId={currentSpeaker?.id} + /> ); @@ -75,36 +82,34 @@ export default function SpeakerMenu(): ReactElement { interface SpeakerMenuListProps { forwardedRef?: ForwardedRef; + onSelect: (speaker?: Speaker) => void; + selectedId?: string; } /** SpeakerMenu options */ export function SpeakerMenuList(props: SpeakerMenuListProps): ReactElement { - const dispatch = useAppDispatch(); - const currentProjId = useSelector( + const projectId = useSelector( (state: StoreState) => state.currentProjectState.project.id ); - const currentSpeaker = useSelector( - (state: StoreState) => state.currentProjectState.speaker - ); const [speakers, setSpeakers] = useState([]); const { t } = useTranslation(); useEffect(() => { - if (currentProjId) { - getAllSpeakers(currentProjId).then(setSpeakers); + if (projectId) { + getAllSpeakers(projectId).then(setSpeakers); } - }, [currentProjId]); + }, [projectId]); const currentIcon = ( ); const speakerMenuItem = (speaker?: Speaker): ReactElement => { - const isCurrent = speaker?.id === currentSpeaker?.id; + const isCurrent = speaker?.id === props.selectedId; return ( (isCurrent ? {} : dispatch(setCurrentSpeaker(speaker)))} + onClick={() => (isCurrent ? {} : props.onSelect(speaker))} > {isCurrent ? currentIcon : } {speaker?.name ?? t("speakerMenu.other")} diff --git a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx index 9e770c611b..fb8d2b1556 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/index.tsx @@ -47,8 +47,9 @@ interface NewEntryProps { resetNewEntry: () => void; updateWordWithNewGloss: (wordId: string) => Promise; newAudio: Pronunciation[]; - addNewAudioUrl: (file: FileWithSpeakerId) => void; - delNewAudioUrl: (url: string) => void; + addNewAudio: (file: FileWithSpeakerId) => void; + delNewAudio: (url: string) => void; + repNewAudio: (audio: Pronunciation) => void; newGloss: string; setNewGloss: (gloss: string) => void; newNote: string; @@ -75,8 +76,9 @@ export default function NewEntry(props: NewEntryProps): ReactElement { resetNewEntry, updateWordWithNewGloss, newAudio, - addNewAudioUrl, - delNewAudioUrl, + addNewAudio, + delNewAudio, + repNewAudio, newGloss, setNewGloss, newNote, @@ -293,8 +295,9 @@ 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 11e3084d6f..3f98cd71c1 100644 --- a/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx +++ b/src/components/DataEntry/DataEntryTable/NewEntry/tests/index.test.tsx @@ -27,8 +27,9 @@ describe("NewEntry", () => { resetNewEntry={jest.fn()} updateWordWithNewGloss={jest.fn()} newAudio={[]} - addNewAudioUrl={jest.fn()} - delNewAudioUrl={jest.fn()} + 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 2cd40fe781..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, @@ -24,7 +24,8 @@ export interface RecentEntryProps { updateVern: (index: number, newVern: string, targetWordId?: string) => void; removeEntry: (index: number) => void; addAudioToWord: (wordId: string, file: FileWithSpeakerId) => void; - deleteAudioFromWord: (wordId: string, fileName: string) => void; + delAudioFromWord: (wordId: string, fileName: string) => void; + repAudioInWord: (wordId: string, audio: Pronunciation) => void; focusNewEntry: () => void; analysisLang: WritingSystem; vernacularLang: WritingSystem; @@ -136,10 +137,13 @@ export function RecentEntry(props: RecentEntryProps): ReactElement { { - props.deleteAudioFromWord(props.entry.id, fileName); + deleteAudio={(fileName) => { + props.delAudioFromWord(props.entry.id, fileName); }} - uploadAudio={(file: FileWithSpeakerId) => { + 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 9a704d5a4d..4fa98a1bcc 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -375,7 +375,7 @@ export default function DataEntryTable( }; /** Add an audio file to newAudioUrls. */ - const addNewAudioUrl = (file: FileWithSpeakerId): void => { + const addNewAudio = (file: FileWithSpeakerId): void => { setState((prevState) => { const newAudio = [...prevState.newAudio]; newAudio.push( @@ -386,13 +386,25 @@ export default function DataEntryTable( }; /** Delete a url from newAudio. */ - const delNewAudioUrl = (url: string): void => { + const delNewAudio = (url: string): void => { setState((prevState) => { 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 = [...prevState.newAudio]; + const oldPro = newAudio.find((a) => a.fileName === pro.fileName); + if (oldPro && !oldPro._protected) { + oldPro.speakerId = pro.speakerId; + } + return { ...prevState, newAudio }; + }); + }; + /** Set the new entry gloss def. */ const setNewGloss = (gloss: string): void => { if (gloss !== state.newGloss) { @@ -625,6 +637,22 @@ 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 oldPro = word.audio.find((a) => a.fileName === pro.fileName); + let newId = oldId; + if (oldPro && oldPro.speakerId !== pro.speakerId && !oldPro._protected) { + oldPro.speakerId = pro.speakerId; + newId = (await backend.updateWord(word)).id; + } + defunctWord(oldId, newId); + }, + [defunctWord] + ); + /** Updates word. */ const updateWordInBackend = useCallback( async (word: Word): Promise => { @@ -911,7 +939,8 @@ export default function DataEntryTable( updateVern={updateRecentVern} removeEntry={undoRecentEntry} addAudioToWord={addAudioFileToWord} - deleteAudioFromWord={deleteAudioFromWord} + delAudioFromWord={deleteAudioFromWord} + repAudioInWord={replaceAudioInWord} focusNewEntry={handleFocusNewEntry} analysisLang={analysisLang} vernacularLang={vernacularLang} @@ -931,8 +960,9 @@ export default function DataEntryTable( resetNewEntry={resetNewEntry} updateWordWithNewGloss={updateWordWithNewEntry} newAudio={state.newAudio} - addNewAudioUrl={addNewAudioUrl} - delNewAudioUrl={delNewAudioUrl} + 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 8a6288d2a0..1101939a44 100644 --- a/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx +++ b/src/components/DataEntry/DataEntryTable/tests/RecentEntry.test.tsx @@ -54,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()} diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx index b3aa5fe49f..bf6732058f 100644 --- a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -17,6 +17,7 @@ import { ViewImageDialog, } from "components/Dialogs"; import AudioPlayer from "components/Pronunciations/AudioPlayer"; +import { newPronunciation } from "types/word"; interface ConsentIconProps { refresh: () => void | Promise; @@ -67,10 +68,10 @@ function PlayConsentListItemIcon(props: ConsentIconProps): ReactElement { return ( diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 7e52a549af..0a44cc49d7 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 { + 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, @@ -21,10 +33,11 @@ import { themeColors } from "types/theme"; interface PlayerProps { deleteAudio: (fileName: string) => void; - fileName: string; + audio: Pronunciation; 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,12 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { ); const { t } = useTranslation(); + useEffect(() => { + if (props.audio.speakerId) { + getSpeaker(props.audio.speakerId).then(setSpeaker); + } + }, [props.audio.speakerId]); + useEffect(() => { if (isPlaying) { audio.addEventListener("ended", dispatchReset); @@ -60,7 +83,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { function togglePlay(): void { if (!isPlaying) { - dispatch(playing(props.fileName)); + dispatch(playing(props.audio.fileName)); } else { dispatchReset(); } @@ -97,16 +120,36 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { setAnchor(event.currentTarget); } + let title = t("pronunciations.playTooltip"); + if (speaker) { + title += ` ${t("pronunciations.speaker", { val: speaker.name })}`; + } + if (props.updateAudioSpeaker && !props.audio._protected) { + title += ` ${ + speaker + ? t("pronunciations.speakerChange") + : t("pronunciations.speakerAdd") + }`; + } + + const handleOnSelect = async (speaker?: Speaker): Promise => { + if (props.updateAudioSpeaker && !props.audio._protected) { + await props.updateAudioSpeaker(speaker?.id); + } + setSpeakerDialog(false); + }; + return ( <> - + setSpeakerDialog(true)} onTouchStart={handleTouch} onTouchEnd={enableContextMenu} aria-label="play" - id={`audio-${props.fileName}`} + id={`audio-${props.audio.fileName}`} size={props.size || "large"} > {isPlaying ? : } @@ -145,10 +188,16 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { textId={props.warningTextId || "buttons.deletePermanently"} titleId="pronunciations.deleteRecording" onClose={() => 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/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index 8f9c69b1ce..199b984661 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -12,6 +12,7 @@ interface PronunciationsBackendProps { overrideMemo?: boolean; wordId: string; deleteAudio: (fileName: string) => void; + replaceAudio: (audio: Pronunciation) => void; uploadAudio?: (file: FileWithSpeakerId) => void; } @@ -28,10 +29,13 @@ export function PronunciationsBackend( const audioButtons: ReactElement[] = props.audio.map((a) => ( + props.replaceAudio({ ...a, speakerId: id ?? "" }) + } /> )); diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index d768a52588..c89ab30704 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -9,6 +9,7 @@ interface PronunciationFrontendProps { audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; + replaceAudio: (audio: Pronunciation) => void; uploadAudio: (file: FileWithSpeakerId) => void; onClick?: () => void; } @@ -19,10 +20,12 @@ export default function PronunciationsFrontend( ): ReactElement { const audioButtons: ReactElement[] = props.audio.map((a) => ( + props.replaceAudio({ ...a, speakerId: id ?? "" }) + } onClick={props.onClick} /> )); diff --git a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx index 007f8e5a46..a744f16753 100644 --- a/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsBackend.test.tsx @@ -30,6 +30,7 @@ const renderPronunciationsBackend = async ( playerOnly={!withRecord} wordId="mock-id" deleteAudio={jest.fn()} + replaceAudio={jest.fn()} uploadAudio={withRecord ? jest.fn() : undefined} /> diff --git a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx index 8f7e15827c..e1f95e8c0c 100644 --- a/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx +++ b/src/components/Pronunciations/tests/PronunciationsFrontend.test.tsx @@ -27,6 +27,7 @@ describe("PronunciationsFrontend", () => { diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index 675e178adf..2faed6acd9 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -79,8 +79,9 @@ export default function WordCard(props: WordCardProps): ReactElement { <> {audio.length > 0 && ( ({ ...a, _protected: true }))} deleteAudio={() => {}} + replaceAudio={() => {}} playerOnly wordId={id} /> diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index 35187bc070..47ab4ea0a1 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -1,6 +1,6 @@ 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, @@ -229,6 +229,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 oldPro = word.audio.find((a) => a.fileName === pro.fileName); + let newId = oldId; + if (oldPro && oldPro.speakerId !== pro.speakerId && !oldPro._protected) { + oldPro.speakerId = pro.speakerId; + newId = (await backend.updateWord(word)).id; + } + return newId; + }); +} + export function uploadAudio( wordId: string, file: FileWithSpeakerId diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx index de64e638e2..70f80dd1b9 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, @@ -382,6 +382,16 @@ const columns: Column[] = [ ), }); }, + repNewAudio: (pro: Pronunciation): void => { + if (props.onRowDataChange && props.rowData.audioNew) { + const audioNew = [...props.rowData.audioNew]; + const oldPro = audioNew.find((a) => a.fileName === pro.fileName); + if (oldPro && !oldPro._protected) { + oldPro.speakerId = pro.speakerId; + props.onRowDataChange({ ...props.rowData, audioNew }); + } + } + }, delOldAudio: (fileName: string): void => { props.onRowDataChange && props.onRowDataChange({ @@ -391,6 +401,16 @@ const columns: Column[] = [ ), }); }, + repOldAudio: (pro: Pronunciation): void => { + if (props.onRowDataChange && props.rowData.audioNew) { + const audio = [...props.rowData.audio]; + const oldPro = audio.find((a) => a.fileName === pro.fileName); + if (oldPro && !oldPro._protected) { + oldPro.speakerId = pro.speakerId; + props.onRowDataChange({ ...props.rowData, audio }); + } + } + }, }} audio={props.rowData.audio} audioNew={props.rowData.audioNew} diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx index 6b234daded..098ae8cea5 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx @@ -5,6 +5,7 @@ import PronunciationsBackend from "components/Pronunciations/PronunciationsBacke import PronunciationsFrontend from "components/Pronunciations/PronunciationsFrontend"; import { deleteAudio, + replaceAudio, uploadAudio, } from "goals/ReviewEntries/Redux/ReviewEntriesActions"; import { useAppDispatch } from "types/hooks"; @@ -14,7 +15,9 @@ interface PronunciationsCellProps { audioFunctions?: { addNewAudio: (file: FileWithSpeakerId) => void; delNewAudio: (url: string) => void; + repNewAudio: (audio: Pronunciation) => void; delOldAudio: (fileName: string) => void; + repOldAudio: (audio: Pronunciation) => void; }; audio: Pronunciation[]; audioNew?: Pronunciation[]; @@ -27,10 +30,13 @@ export default function PronunciationsCell( const dispatch = useAppDispatch(); const dispatchDelete = (fileName: string): Promise => dispatch(deleteAudio(props.wordId, fileName)); + const dispatchReplace = (audio: Pronunciation): Promise => + dispatch(replaceAudio(props.wordId, audio)); const dispatchUpload = (file: FileWithSpeakerId): Promise => dispatch(uploadAudio(props.wordId, file)); - const { addNewAudio, delNewAudio, delOldAudio } = props.audioFunctions ?? {}; + const { addNewAudio, delNewAudio, repNewAudio, delOldAudio, repOldAudio } = + props.audioFunctions ?? {}; return props.audioFunctions ? ( } audio={props.audioNew ?? []} deleteAudio={delNewAudio!} + replaceAudio={repNewAudio!} uploadAudio={addNewAudio!} /> ) : ( @@ -52,6 +60,7 @@ export default function PronunciationsCell( audio={props.audio} wordId={props.wordId} deleteAudio={dispatchDelete} + replaceAudio={dispatchReplace} uploadAudio={dispatchUpload} /> ); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx index 61c9fe0c26..cf93ffe7d3 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/tests/PronunciationsCell.test.tsx @@ -38,11 +38,15 @@ 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 From fc908d583799f6ef922359f49e061a9cdc79ed75 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 6 Dec 2023 11:46:09 -0500 Subject: [PATCH 27/56] Fix several bugs --- Backend/Controllers/AudioController.cs | 20 +- public/locales/en/translation.json | 7 +- src/api/api/audio-api.ts | 226 +++++++++++++++++- src/backend/index.ts | 11 +- .../DataEntry/DataEntryTable/index.tsx | 19 +- src/components/Dialogs/RecordAudioDialog.tsx | 1 + .../SpeakerConsentListItemIcon.tsx | 2 +- src/components/Pronunciations/AudioPlayer.tsx | 75 ++++-- .../Pronunciations/AudioRecorder.tsx | 11 +- src/components/Pronunciations/utilities.ts | 4 +- .../Redux/ReviewEntriesActions.ts | 16 +- .../ReviewEntriesTable/CellColumns.tsx | 23 +- src/types/word.ts | 20 ++ 13 files changed, 355 insertions(+), 80 deletions(-) diff --git a/Backend/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index 4f1aa29f07..da8efecf33 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -61,11 +61,25 @@ 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/{speakerId}", Name = "UploadAudioFile")] + [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) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c7f99f7e09..df1696a482 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -461,10 +461,11 @@ }, "pronunciations": { "recordTooltip": "Press and hold to record.", - "playTooltip": "Click to play; shift click to delete.", + "playTooltip": "Click to play.", + "deleteTooltip": "Shift click to delete.", "speaker": "Speaker: {{ val }}.", - "speakerAdd": "Right-click to add a speaker to this recording.", - "speakerChange": "Right-click to change speaker.", + "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/src/api/api/audio-api.ts b/src/api/api/audio-api.ts index 5bfd51ca77..62e9438bfa 100644 --- a/src/api/api/audio-api.ts +++ b/src/api/api/audio-api.ts @@ -156,7 +156,6 @@ export const AudioApiAxiosParamCreator = function ( * * @param {string} projectId * @param {string} wordId - * @param {string} speakerId * @param {any} file * @param {string} name * @param {string} filePath @@ -166,7 +165,6 @@ export const AudioApiAxiosParamCreator = function ( uploadAudioFile: async ( projectId: string, wordId: string, - speakerId: string, file: any, name: string, filePath: string, @@ -176,14 +174,95 @@ export const AudioApiAxiosParamCreator = function ( assertParamExists("uploadAudioFile", "projectId", projectId); // verify required parameter 'wordId' is not null or undefined assertParamExists("uploadAudioFile", "wordId", wordId); - // verify required parameter 'speakerId' is not null or undefined - assertParamExists("uploadAudioFile", "speakerId", speakerId); // verify required parameter 'file' is not null or undefined assertParamExists("uploadAudioFile", "file", file); // verify required parameter 'name' is not null or undefined assertParamExists("uploadAudioFile", "name", name); // verify required parameter 'filePath' is not null or undefined assertParamExists("uploadAudioFile", "filePath", filePath); + const localVarPath = + `/v1/projects/{projectId}/words/{wordId}/audio/upload` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"wordId"}}`, encodeURIComponent(String(wordId))); + // 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, + }; + }, + /** + * + * @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))) @@ -309,7 +388,6 @@ export const AudioApiFp = function (configuration?: Configuration) { * * @param {string} projectId * @param {string} wordId - * @param {string} speakerId * @param {any} file * @param {string} name * @param {string} filePath @@ -319,7 +397,6 @@ export const AudioApiFp = function (configuration?: Configuration) { async uploadAudioFile( projectId: string, wordId: string, - speakerId: string, file: any, name: string, filePath: string, @@ -330,7 +407,6 @@ export const AudioApiFp = function (configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.uploadAudioFile( projectId, wordId, - speakerId, file, name, filePath, @@ -343,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 + ); + }, }; }; @@ -397,7 +512,6 @@ export const AudioApiFactory = function ( * * @param {string} projectId * @param {string} wordId - * @param {string} speakerId * @param {any} file * @param {string} name * @param {string} filePath @@ -405,6 +519,29 @@ export const AudioApiFactory = function ( * @throws {RequiredError} */ uploadAudioFile( + projectId: string, + wordId: string, + file: any, + name: string, + filePath: string, + options?: any + ): AxiosPromise { + return localVarFp + .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, @@ -414,7 +551,7 @@ export const AudioApiFactory = function ( options?: any ): AxiosPromise { return localVarFp - .uploadAudioFile( + .uploadAudioFileWithSpeaker( projectId, wordId, speakerId, @@ -504,31 +641,73 @@ export interface AudioApiUploadAudioFileRequest { */ readonly wordId: string; + /** + * + * @type {any} + * @memberof AudioApiUploadAudioFile + */ + readonly file: any; + + /** + * + * @type {string} + * @memberof AudioApiUploadAudioFile + */ + readonly name: string; + /** * * @type {string} * @memberof AudioApiUploadAudioFile */ + 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 AudioApiUploadAudioFile + * @memberof AudioApiUploadAudioFileWithSpeaker */ readonly file: any; /** * * @type {string} - * @memberof AudioApiUploadAudioFile + * @memberof AudioApiUploadAudioFileWithSpeaker */ readonly name: string; /** * * @type {string} - * @memberof AudioApiUploadAudioFile + * @memberof AudioApiUploadAudioFileWithSpeaker */ readonly filePath: string; } @@ -595,6 +774,29 @@ export class AudioApi extends BaseAPI { ) { return AudioApiFp(this.configuration) .uploadAudioFile( + requestParameters.projectId, + requestParameters.wordId, + requestParameters.file, + requestParameters.name, + requestParameters.filePath, + options + ) + .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, diff --git a/src/backend/index.ts b/src/backend/index.ts index 612627c47b..00bdefb192 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -129,11 +129,12 @@ export async function uploadAudio( ): Promise { const projectId = LocalStorage.getProjectId(); const speakerId = file.speakerId ?? ""; - const resp = await audioApi.uploadAudioFile( - { projectId, speakerId, wordId, ...fileUpload(file) }, - { headers: { ...authHeader(), "content-type": "application/json" } } - ); - return resp.data; + 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( diff --git a/src/components/DataEntry/DataEntryTable/index.tsx b/src/components/DataEntry/DataEntryTable/index.tsx index 4fa98a1bcc..c644c99e72 100644 --- a/src/components/DataEntry/DataEntryTable/index.tsx +++ b/src/components/DataEntry/DataEntryTable/index.tsx @@ -42,6 +42,7 @@ import { newSense, newWord, simpleWord, + updateSpeakerInAudio, } from "types/word"; import { defaultWritingSystem } from "types/writingSystem"; import SpellCheckerContext from "utilities/spellCheckerContext"; @@ -396,12 +397,8 @@ export default function DataEntryTable( /** Replace the speaker of a newAudio. */ const repNewAudio = (pro: Pronunciation): void => { setState((prevState) => { - const newAudio = [...prevState.newAudio]; - const oldPro = newAudio.find((a) => a.fileName === pro.fileName); - if (oldPro && !oldPro._protected) { - oldPro.speakerId = pro.speakerId; - } - return { ...prevState, newAudio }; + const newAudio = updateSpeakerInAudio(prevState.newAudio, pro); + return newAudio ? { ...prevState, newAudio } : prevState; }); }; @@ -642,12 +639,10 @@ export default function DataEntryTable( async (oldId: string, pro: Pronunciation): Promise => { defunctWord(oldId); const word = await backend.getWord(oldId); - const oldPro = word.audio.find((a) => a.fileName === pro.fileName); - let newId = oldId; - if (oldPro && oldPro.speakerId !== pro.speakerId && !oldPro._protected) { - oldPro.speakerId = pro.speakerId; - newId = (await backend.updateWord(word)).id; - } + const audio = updateSpeakerInAudio(word.audio, pro); + const newId = audio + ? (await backend.updateWord({ ...word, audio })).id + : oldId; defunctWord(oldId, newId); }, [defunctWord] diff --git a/src/components/Dialogs/RecordAudioDialog.tsx b/src/components/Dialogs/RecordAudioDialog.tsx index e45a498ac6..93d714aa45 100644 --- a/src/components/Dialogs/RecordAudioDialog.tsx +++ b/src/components/Dialogs/RecordAudioDialog.tsx @@ -23,6 +23,7 @@ export default function RecordAudioDialog( props.uploadAudio(file)} /> diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx index bf6732058f..428a033398 100644 --- a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -68,7 +68,7 @@ function PlayConsentListItemIcon(props: ConsentIconProps): ReactElement { return ( { if (props.audio.speakerId) { getSpeaker(props.audio.speakerId).then(setSpeaker); @@ -100,52 +102,72 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { } } - 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); + disableContextMenu(); setAnchor(event.currentTarget); } - let title = t("pronunciations.playTooltip"); + 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"), + t("pronunciations.deleteTooltip"), + ]; if (speaker) { - title += ` ${t("pronunciations.speaker", { val: speaker.name })}`; + tooltipTexts.push(t("pronunciations.speaker", { val: speaker.name })); } - if (props.updateAudioSpeaker && !props.audio._protected) { - title += ` ${ + if (canChangeSpeaker) { + tooltipTexts.push( speaker ? t("pronunciations.speakerChange") : t("pronunciations.speakerAdd") - }`; + ); } - const handleOnSelect = async (speaker?: Speaker): Promise => { - if (props.updateAudioSpeaker && !props.audio._protected) { - await props.updateAudioSpeaker(speaker?.id); - } - setSpeakerDialog(false); - }; + const multilineTooltipText = (lines: string[]): ReactElement => ( +
{lines.join("\n")}
+ ); return ( <> - + setSpeakerDialog(true)} onTouchStart={handleTouch} onTouchEnd={enableContextMenu} aria-label="play" @@ -160,7 +182,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" }} > @@ -168,16 +190,27 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { id={isPlaying ? "audio-stop" : "audio-play"} onClick={() => { togglePlay(); - handleClose(); + handleMenuOnClose(); }} > {isPlaying ? : }
+ {canChangeSpeaker && ( + { + setSpeakerDialog(true); + handleMenuOnClose(); + }} + > + + + )} { setDeleteConf(true); - handleClose(); + handleMenuOnClose(); }} > diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index 8cc3b71257..04c0225fe8 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -13,6 +13,7 @@ import { FileWithSpeakerId } from "types/word"; interface RecorderProps { id: string; uploadAudio: (file: FileWithSpeakerId) => void; + noSpeaker?: boolean; onClick?: () => void; } @@ -37,12 +38,14 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { return; } const fileName = getFileNameForWord(props.id); - const options: FilePropertyBag = { + const file = new File([blob], fileName, { lastModified: Date.now(), type: Recorder.blobType, - }; - const file = new File([blob], fileName, options); - props.uploadAudio({ ...file, speakerId }); + }); + if (!props.noSpeaker) { + (file as FileWithSpeakerId).speakerId = speakerId; + } + props.uploadAudio(file); } return ( diff --git a/src/components/Pronunciations/utilities.ts b/src/components/Pronunciations/utilities.ts index 94a5760e37..3f43592ff9 100644 --- a/src/components/Pronunciations/utilities.ts +++ b/src/components/Pronunciations/utilities.ts @@ -1,5 +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 { @@ -23,7 +24,8 @@ export async function uploadFileFromPronunciation( type: audioBlob.type, lastModified: Date.now(), }); - const newId = await uploadAudio(wordId, { ...file, speakerId }); + (file as FileWithSpeakerId).speakerId = speakerId; + const newId = await uploadAudio(wordId, file); URL.revokeObjectURL(fileName); return newId; } diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index 47ab4ea0a1..f87547d566 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -20,7 +20,12 @@ import { ReviewEntriesWord, } from "goals/ReviewEntries/ReviewEntriesTypes"; import { StoreStateDispatch } from "types/Redux/actions"; -import { FileWithSpeakerId, newNote, newSense } from "types/word"; +import { + FileWithSpeakerId, + newNote, + newSense, + updateSpeakerInAudio, +} from "types/word"; // Action Creation Functions @@ -235,13 +240,8 @@ export function replaceAudio( ): (dispatch: StoreStateDispatch) => Promise { return asyncRefreshWord(wordId, async (oldId: string) => { const word = await backend.getWord(oldId); - const oldPro = word.audio.find((a) => a.fileName === pro.fileName); - let newId = oldId; - if (oldPro && oldPro.speakerId !== pro.speakerId && !oldPro._protected) { - oldPro.speakerId = pro.speakerId; - newId = (await backend.updateWord(word)).id; - } - return newId; + const audio = updateSpeakerInAudio(word.audio, pro); + return audio ? (await backend.updateWord({ ...word, audio })).id : oldId; }); } diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx index 70f80dd1b9..637c613906 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellColumns.tsx @@ -21,7 +21,11 @@ import { ReviewEntriesWord, ReviewEntriesWordField, } from "goals/ReviewEntries/ReviewEntriesTypes"; -import { FileWithSpeakerId, newPronunciation } from "types/word"; +import { + FileWithSpeakerId, + newPronunciation, + updateSpeakerInAudio, +} from "types/word"; import { compareFlags } from "utilities/wordUtilities"; export class ColumnTitle { @@ -384,10 +388,11 @@ const columns: Column[] = [ }, repNewAudio: (pro: Pronunciation): void => { if (props.onRowDataChange && props.rowData.audioNew) { - const audioNew = [...props.rowData.audioNew]; - const oldPro = audioNew.find((a) => a.fileName === pro.fileName); - if (oldPro && !oldPro._protected) { - oldPro.speakerId = pro.speakerId; + const audioNew = updateSpeakerInAudio( + props.rowData.audioNew, + pro + ); + if (audioNew) { props.onRowDataChange({ ...props.rowData, audioNew }); } } @@ -402,11 +407,9 @@ const columns: Column[] = [ }); }, repOldAudio: (pro: Pronunciation): void => { - if (props.onRowDataChange && props.rowData.audioNew) { - const audio = [...props.rowData.audio]; - const oldPro = audio.find((a) => a.fileName === pro.fileName); - if (oldPro && !oldPro._protected) { - oldPro.speakerId = pro.speakerId; + if (props.onRowDataChange) { + const audio = updateSpeakerInAudio(props.rowData.audio, pro); + if (audio) { props.onRowDataChange({ ...props.rowData, audio }); } } diff --git a/src/types/word.ts b/src/types/word.ts index fdaac49dea..f53d6f74f0 100644 --- a/src/types/word.ts +++ b/src/types/word.ts @@ -23,6 +23,26 @@ 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 }; } From 4bbfa05c03d2a1d07ce824a9545a6aab234703c1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 6 Dec 2023 16:11:41 -0500 Subject: [PATCH 28/56] Add tests for SpeakerController --- .../Controllers/SpeakerControllerTests.cs | 377 ++++++++++++++++++ Backend/Controllers/SpeakerController.cs | 10 + 2 files changed, 387 insertions(+) create mode 100644 Backend.Tests/Controllers/SpeakerControllerTests.cs diff --git a/Backend.Tests/Controllers/SpeakerControllerTests.cs b/Backend.Tests/Controllers/SpeakerControllerTests.cs new file mode 100644 index 0000000000..fd06410fbd --- /dev/null +++ b/Backend.Tests/Controllers/SpeakerControllerTests.cs @@ -0,0 +1,377 @@ +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()); + } + + /*[Test] + public void TestUploadConsent() + { + const string soundFileName = "sound.mp3"; + var filePath = Path.Combine(Util.AssetsDir, soundFileName); + + // 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" }; + + var word = _wordRepo.Create(Util.RandomWord(_projId)).Result; + + // `fileUpload` contains the file stream and the name of the file. + _ = _speakerController.UploadConsent(ProjId, word.Id, "", fileUpload).Result; + + var foundWord = _wordRepo.GetWord(_projId, word.Id).Result; + Assert.That(foundWord?.Speaker, Is.Not.Null); + } + + [Test] + public void DeleteSpeaker() + { + // Fill test database + var origWord = Util.RandomWord(_projId); + var fileName = "a.wav"; + origWord.Speaker.Add(new Pronunciation(fileName)); + var wordId = _wordRepo.Create(origWord).Result.Id; + + // Test delete function + _ = _speakerController.DeleteSpeakerFile(_projId, wordId, fileName).Result; + + // Original word persists + Assert.That(_wordRepo.GetAllWords(_projId).Result, Has.Count.EqualTo(2)); + + // Get the new word from the database + var frontier = _wordRepo.GetFrontier(_projId).Result; + + // Ensure the new word has no speaker files + Assert.That(frontier[0].Speaker, Has.Count.EqualTo(0)); + + // Test the frontier + Assert.That(_wordRepo.GetFrontier(_projId).Result, Has.Count.EqualTo(1)); + + // Ensure the word with deleted speaker is in the frontier + Assert.That(frontier, Has.Count.EqualTo(1)); + Assert.That(frontier[0].Id, Is.Not.EqualTo(wordId)); + Assert.That(frontier[0].Speaker, Has.Count.EqualTo(0)); + Assert.That(frontier[0].History, Has.Count.EqualTo(1)); + }*/ + } +} diff --git a/Backend/Controllers/SpeakerController.cs b/Backend/Controllers/SpeakerController.cs index a4dbd0fc34..4e83d3a0d2 100644 --- a/Backend/Controllers/SpeakerController.cs +++ b/Backend/Controllers/SpeakerController.cs @@ -273,6 +273,16 @@ public IActionResult DownloadConsent(string speakerId) // 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)) From 17bf947f0880ac54c268a228ea3f709279cc95de Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 09:56:55 -0500 Subject: [PATCH 29/56] Include consent files in export --- Backend/Services/LiftService.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 38c514106c..d2cd6b3609 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -260,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 @@ -290,7 +292,7 @@ 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. + // 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. @@ -340,6 +342,21 @@ public async Task LiftExport( 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) { From 03f4214fd0069af6db91d0bd3952e0dfa9fcf33a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 10:16:17 -0500 Subject: [PATCH 30/56] Show message in speaker menu when no project speakers --- public/locales/en/translation.json | 1 + src/components/AppBar/SpeakerMenu.tsx | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index df1696a482..7e5ce896d0 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -80,6 +80,7 @@ "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": { diff --git a/src/components/AppBar/SpeakerMenu.tsx b/src/components/AppBar/SpeakerMenu.tsx index 098a655e47..5b7a434794 100644 --- a/src/components/AppBar/SpeakerMenu.tsx +++ b/src/components/AppBar/SpeakerMenu.tsx @@ -7,6 +7,7 @@ import { ListItemText, Menu, MenuItem, + Typography, } from "@mui/material"; import { ForwardedRef, @@ -119,9 +120,19 @@ export function SpeakerMenuList(props: SpeakerMenuListProps): ReactElement { return (
- {speakers.map((s) => speakerMenuItem(s))} - - {speakerMenuItem()} + {speakers.length ? ( + <> + {speakers.map((s) => speakerMenuItem(s))} + + {speakerMenuItem()} + + ) : ( + + + {t("speakerMenu.none")} + + + )}
); } From 46569dcb92132aeb3f7d9cfb9eea65c0045e8bb3 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 10:55:54 -0500 Subject: [PATCH 31/56] Update WordService tests --- Backend.Tests/Services/WordServiceTests.cs | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) 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() { From 660152654c7683926012a5247ce2adcaa036996a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 12:07:47 -0500 Subject: [PATCH 32/56] Update LiftController tests --- .../Controllers/LiftControllerTests.cs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 84e379a976..1bd4852bdf 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -514,17 +514,27 @@ 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 audio in allWords[0].Audio) + foreach (var audio in word.Audio) { Assert.That(roundTripObj.AudioFiles, Does.Contain(Path.GetFileName(audio.FileName))); } + if (word.Audio.Count == 1) + { + // None of the cases have labels on audio, so the audio isn't protected and we can add a speaker. + Assert.That(word.Audio[0].Protected, Is.False); + var speaker = _speakerRepo.Create(new() { Consent = ConsentType.Audio, ProjectId = proj1.Id }).Result; + word.Audio[0].SpeakerId = speaker.Id; + _wordRepo.DeleteAllWords(proj1.Id); + _wordRepo.Create(word); + } } // Assert that the first SemanticDomain doesn't have an empty MongoId. @@ -587,13 +597,21 @@ public void TestRoundtrip(RoundTripObj roundTripObj) allWords = _wordRepo.GetAllWords(proj2.Id).Result; Assert.That(allWords, Has.Count.EqualTo(roundTripObj.NumOfWords)); - // We are currently only testing guids on the single-entry data sets. + // We are currently only testing guids and audio 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)); + } + + if (word.Audio.Count == 1) + { + // The speaker added in Roundtrip Part 1 should have exported as an English label. + Assert.That(word.Audio[0].Protected, Is.True); } } From 9ecbbf9dc8bc85d80befd5acc5641fa933e777b7 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 12:40:15 -0500 Subject: [PATCH 33/56] Fix comments --- .../Controllers/AudioControllerTests.cs | 2 +- .../Controllers/SpeakerControllerTests.cs | 53 +------------------ Backend/Models/Speaker.cs | 4 +- 3 files changed, 4 insertions(+), 55 deletions(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 1cd22a03b0..bc20c2397d 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -76,7 +76,7 @@ public void TestDownloadAudioFileNoFile() [Test] public void TestAudioImport() { - const string soundFileName = "sound.mp3"; + const string soundFileName = "sound.mp3"; // file in Backend.Tests/Assets/ var filePath = Path.Combine(Util.AssetsDir, soundFileName); // Open the file to read to controller. diff --git a/Backend.Tests/Controllers/SpeakerControllerTests.cs b/Backend.Tests/Controllers/SpeakerControllerTests.cs index fd06410fbd..a53218b4b2 100644 --- a/Backend.Tests/Controllers/SpeakerControllerTests.cs +++ b/Backend.Tests/Controllers/SpeakerControllerTests.cs @@ -33,7 +33,7 @@ protected virtual void Dispose(bool disposing) private const string ProjId = "proj-id"; private const string Name = "Madam Name"; - private const string FileName = "sound.mp3"; // file in Backend.Tests/Assets + 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!; @@ -322,56 +322,5 @@ public void TestDownloadSpeakerFileNoFile() var result = _speakerController.DownloadConsent("speakerId"); Assert.That(result, Is.TypeOf()); } - - /*[Test] - public void TestUploadConsent() - { - const string soundFileName = "sound.mp3"; - var filePath = Path.Combine(Util.AssetsDir, soundFileName); - - // 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" }; - - var word = _wordRepo.Create(Util.RandomWord(_projId)).Result; - - // `fileUpload` contains the file stream and the name of the file. - _ = _speakerController.UploadConsent(ProjId, word.Id, "", fileUpload).Result; - - var foundWord = _wordRepo.GetWord(_projId, word.Id).Result; - Assert.That(foundWord?.Speaker, Is.Not.Null); - } - - [Test] - public void DeleteSpeaker() - { - // Fill test database - var origWord = Util.RandomWord(_projId); - var fileName = "a.wav"; - origWord.Speaker.Add(new Pronunciation(fileName)); - var wordId = _wordRepo.Create(origWord).Result.Id; - - // Test delete function - _ = _speakerController.DeleteSpeakerFile(_projId, wordId, fileName).Result; - - // Original word persists - Assert.That(_wordRepo.GetAllWords(_projId).Result, Has.Count.EqualTo(2)); - - // Get the new word from the database - var frontier = _wordRepo.GetFrontier(_projId).Result; - - // Ensure the new word has no speaker files - Assert.That(frontier[0].Speaker, Has.Count.EqualTo(0)); - - // Test the frontier - Assert.That(_wordRepo.GetFrontier(_projId).Result, Has.Count.EqualTo(1)); - - // Ensure the word with deleted speaker is in the frontier - Assert.That(frontier, Has.Count.EqualTo(1)); - Assert.That(frontier[0].Id, Is.Not.EqualTo(wordId)); - Assert.That(frontier[0].Speaker, Has.Count.EqualTo(0)); - Assert.That(frontier[0].History, Has.Count.EqualTo(1)); - }*/ } } diff --git a/Backend/Models/Speaker.cs b/Backend/Models/Speaker.cs index a71c95b97b..e1333f0b07 100644 --- a/Backend/Models/Speaker.cs +++ b/Backend/Models/Speaker.cs @@ -6,8 +6,8 @@ namespace BackendFramework.Models { /// - /// Helper object that contains a parent word and a number of children which will be merged into it - /// along with the userId of who made the merge and at what time + /// 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 { From 589309da4da4d279f60926dcb5caed71063ba734 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 14:13:05 -0500 Subject: [PATCH 34/56] Remove unnecessary deduplication --- Backend/Services/MergeService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 58f012df1c..9f40ed2621 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -50,8 +50,6 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords } // Remove duplicates. - // TODO: Confirm this works with Audio now List - parent.Audio = parent.Audio.Distinct().ToList(); parent.History = parent.History.Distinct().ToList(); return parent; } From 684915a44dcd5617dceb542515779b1a6f129301 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 14:31:52 -0500 Subject: [PATCH 35/56] Update AppBar ProjectButtons tests --- .../AppBar/tests/ProjectButtons.test.tsx | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/components/AppBar/tests/ProjectButtons.test.tsx b/src/components/AppBar/tests/ProjectButtons.test.tsx index f5a945fa7f..792233840e 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,7 +10,11 @@ import ProjectButtons, { projButtonId, statButtonId, } from "components/AppBar/ProjectButtons"; -import { Goal } from "types/goals"; +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"; @@ -30,15 +34,19 @@ mockProjectRoles[mockProjectId] = "non-empty-string"; let testRenderer: ReactTestRenderer; -const mockStore = configureMockStore()({ - currentProjectState: { project: { name: "" } }, - goalsState: { currentGoal: new Goal() }, -}); +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( - + ); @@ -56,12 +64,34 @@ 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 () => { + mockHasPermission.mockResolvedValueOnce(true); + 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 }); From 1e51b5d0c57cefbbb5e94237d9e79efa443e9788 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 14:49:53 -0500 Subject: [PATCH 36/56] Alphabetize --- src/components/Pronunciations/AudioRecorder.tsx | 2 +- src/components/Pronunciations/PronunciationsFrontend.tsx | 4 ++-- src/components/WordCard/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index 04c0225fe8..ac307873b3 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -12,9 +12,9 @@ import { FileWithSpeakerId } from "types/word"; interface RecorderProps { id: string; - uploadAudio: (file: FileWithSpeakerId) => void; noSpeaker?: boolean; onClick?: () => void; + uploadAudio: (file: FileWithSpeakerId) => void; } export default function AudioRecorder(props: RecorderProps): ReactElement { diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index c89ab30704..083cfccab7 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -21,12 +21,12 @@ export default function PronunciationsFrontend( const audioButtons: ReactElement[] = props.audio.map((a) => ( props.replaceAudio({ ...a, speakerId: id ?? "" }) } - onClick={props.onClick} /> )); diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index 2faed6acd9..b2170a7f51 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -81,8 +81,8 @@ export default function WordCard(props: WordCardProps): ReactElement { ({ ...a, _protected: true }))} deleteAudio={() => {}} - replaceAudio={() => {}} playerOnly + replaceAudio={() => {}} wordId={id} /> )} From a6350b1b91d9fda6afdbeeb3c5fc84b1fdd3c4e8 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 15:27:49 -0500 Subject: [PATCH 37/56] Add SpeakerMenu tests --- .../AppBar/tests/SpeakerMenu.test.tsx | 101 ++++++++++++++++++ src/types/project.ts | 11 +- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/components/AppBar/tests/SpeakerMenu.test.tsx 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/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, + }; +} From bce11e173ccd403b66d4c0838c9aa19157e5d8b2 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 7 Dec 2023 17:12:42 -0500 Subject: [PATCH 38/56] Update ReviewEntriesActions tests --- .../Redux/tests/ReviewEntriesActions.test.tsx | 113 +++++++++++++++++- 1 file changed, 109 insertions(+), 4 deletions(-) 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()); From 99a7fd9f648cd1eac633728202545c00f59cee40 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 8 Dec 2023 14:52:59 -0500 Subject: [PATCH 39/56] Add SpeakerConsentListItemIcon tests --- .../SpeakerConsentListItemIcon.tsx | 18 ++- .../tests/SpeakerConsentListItemIcon.test.tsx | 130 ++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/components/ProjectUsers/tests/SpeakerConsentListItemIcon.test.tsx diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx index 428a033398..23f75f06a0 100644 --- a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -19,6 +19,14 @@ import { 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; @@ -39,7 +47,7 @@ export default function SpeakerConsentListItemIcon( ) : props.speaker.consent === ConsentType.Image ? ( ) : ( - + } @@ -66,7 +74,7 @@ function PlayConsentListItemIcon(props: ConsentIconProps): ReactElement { }; return ( - + + } @@ -125,7 +133,7 @@ function RecordConsentMenuItem(props: ConsentIconProps): ReactElement { return ( setOpen(true)}> - + {t("projectSettings.speaker.consent.record")} @@ -152,7 +160,7 @@ function UploadConsentMenuItem(props: ConsentIconProps): ReactElement { return ( setOpen(true)}> - + {t("projectSettings.speaker.consent.upload")} 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; + }); + }); +}); From 0989a19288960634db199c9285d773cbad689cad Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 8 Dec 2023 15:10:55 -0500 Subject: [PATCH 40/56] Add ProjectSpeakersList test --- .../ProjectUsers/ProjectSpeakersList.tsx | 4 +- .../tests/ProjectSpeakersList.test.tsx | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/components/ProjectUsers/tests/ProjectSpeakersList.test.tsx diff --git a/src/components/ProjectUsers/ProjectSpeakersList.tsx b/src/components/ProjectUsers/ProjectSpeakersList.tsx index 839f69cdb3..6b522729c7 100644 --- a/src/components/ProjectUsers/ProjectSpeakersList.tsx +++ b/src/components/ProjectUsers/ProjectSpeakersList.tsx @@ -55,7 +55,7 @@ interface ProjSpeakerProps { speaker: Speaker; } -function SpeakerListItem(props: ProjSpeakerProps): ReactElement { +export function SpeakerListItem(props: ProjSpeakerProps): ReactElement { const { refresh, speaker } = props; return ( @@ -126,7 +126,7 @@ interface AddSpeakerProps { refresh: () => void | Promise; } -function AddSpeakerListItem(props: AddSpeakerProps): ReactElement { +export function AddSpeakerListItem(props: AddSpeakerProps): ReactElement { const [open, setOpen] = useState(false); const handleSubmitText = async (name: string): Promise => { 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; + }); +}); From f880bc300a0b6b8c9dfed1f666bd6e3724511012 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 8 Dec 2023 16:47:14 -0500 Subject: [PATCH 41/56] Fix up speaker management consent dialogs --- public/locales/en/translation.json | 3 +- src/components/Dialogs/RecordAudioDialog.tsx | 9 +++- src/components/Dialogs/UploadImageDialog.tsx | 9 +++- src/components/Dialogs/ViewImageDialog.tsx | 7 +--- .../SpeakerConsentListItemIcon.tsx | 42 ++++++++++++------- 5 files changed, 45 insertions(+), 25 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 7e5ce896d0..215f0f75b5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -212,7 +212,8 @@ "play": "Listen to the audio consent for this speaker", "add": "Add consent for this speaker", "record": "Record audio consent", - "look": "Look at the image consent for this speaker", + "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." diff --git a/src/components/Dialogs/RecordAudioDialog.tsx b/src/components/Dialogs/RecordAudioDialog.tsx index 93d714aa45..218ab759ce 100644 --- a/src/components/Dialogs/RecordAudioDialog.tsx +++ b/src/components/Dialogs/RecordAudioDialog.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 AudioRecorder from "components/Pronunciations/AudioRecorder"; interface RecordAudioDialogProps { @@ -19,7 +20,11 @@ export default function RecordAudioDialog( return ( - {t(props.titleId)} + + {t(props.titleId)} + + + - {t(props.titleId)} + + {t(props.titleId)} + + + => { if (props.deleteImage) { await props.deleteImage(); @@ -29,7 +26,7 @@ export default function ViewImageDialog( return ( - {t(props.titleId)} + {props.title} diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx index 23f75f06a0..6eac6aae52 100644 --- a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -90,6 +90,8 @@ 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]); @@ -103,16 +105,18 @@ function ShowConsentListItemIcon(props: ConsentIconProps): ReactElement { return ( } onClick={() => setOpen(true)} - textId="projectSettings.speaker.consent.look" + textId="projectSettings.speaker.consent.view" /> setOpen(false)} imgSrc={imgSrc} open={open} - titleId="projectSettings.speaker.consent.look" + title={t("projectSettings.speaker.consent.viewTitle", { + val: props.speaker.name, + })} deleteImage={handleDeleteImage} deleteTextId="projectSettings.speaker.consent.remove" /> @@ -132,11 +136,15 @@ function RecordConsentMenuItem(props: ConsentIconProps): ReactElement { }; return ( - setOpen(true)}> - - - - {t("projectSettings.speaker.consent.record")} + <> + setOpen(true)}> + + + + + {t("projectSettings.speaker.consent.record")} + + setOpen(false)} @@ -144,7 +152,7 @@ function RecordConsentMenuItem(props: ConsentIconProps): ReactElement { titleId="projectSettings.speaker.consent.record" uploadAudio={handleUploadAudio} /> - + ); } @@ -159,17 +167,21 @@ function UploadConsentMenuItem(props: ConsentIconProps): ReactElement { }; return ( - setOpen(true)}> - - - - {t("projectSettings.speaker.consent.upload")} + <> + setOpen(true)}> + + + + + {t("projectSettings.speaker.consent.upload")} + + setOpen(false)} open={open} titleId="projectSettings.speaker.consent.upload" uploadImage={handleUploadImage} /> - + ); } From d10b368a02ec17ad3d3f7695e118d042a863562f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 8 Dec 2023 17:02:59 -0500 Subject: [PATCH 42/56] Add missing s --- src/components/Pronunciations/PronunciationsFrontend.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Pronunciations/PronunciationsFrontend.tsx b/src/components/Pronunciations/PronunciationsFrontend.tsx index 083cfccab7..d66d7718f3 100644 --- a/src/components/Pronunciations/PronunciationsFrontend.tsx +++ b/src/components/Pronunciations/PronunciationsFrontend.tsx @@ -5,7 +5,7 @@ import AudioPlayer from "components/Pronunciations/AudioPlayer"; import AudioRecorder from "components/Pronunciations/AudioRecorder"; import { FileWithSpeakerId } from "types/word"; -interface PronunciationFrontendProps { +interface PronunciationsFrontendProps { audio: Pronunciation[]; elemBetweenRecordAndPlay?: ReactElement; deleteAudio: (fileName: string) => void; @@ -16,7 +16,7 @@ interface PronunciationFrontendProps { /** 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.audio.map((a) => ( Date: Fri, 8 Dec 2023 17:21:00 -0500 Subject: [PATCH 43/56] Fix bug in row-edit mode: tooltip no update when speakerId removed --- src/components/Pronunciations/AudioPlayer.tsx | 2 ++ .../CellComponents/PronunciationsCell.tsx | 13 +++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 24e4ad6d74..8f1d4a6856 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -70,6 +70,8 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { useEffect(() => { if (props.audio.speakerId) { getSpeaker(props.audio.speakerId).then(setSpeaker); + } else { + setSpeaker(undefined); } }, [props.audio.speakerId]); diff --git a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx index 098ae8cea5..6cc5dacd98 100644 --- a/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx +++ b/src/goals/ReviewEntries/ReviewEntriesTable/CellComponents/PronunciationsCell.tsx @@ -35,9 +35,6 @@ export default function PronunciationsCell( const dispatchUpload = (file: FileWithSpeakerId): Promise => dispatch(uploadAudio(props.wordId, file)); - const { addNewAudio, delNewAudio, repNewAudio, delOldAudio, repOldAudio } = - props.audioFunctions ?? {}; - return props.audioFunctions ? ( } audio={props.audioNew ?? []} - deleteAudio={delNewAudio!} - replaceAudio={repNewAudio!} - uploadAudio={addNewAudio!} + deleteAudio={props.audioFunctions.delNewAudio} + replaceAudio={props.audioFunctions.repNewAudio} + uploadAudio={props.audioFunctions.addNewAudio} /> ) : ( Date: Fri, 8 Dec 2023 17:42:52 -0500 Subject: [PATCH 44/56] Fix bug is row-edit mode: audio w/ diff speakerId are lost --- .../Redux/ReviewEntriesActions.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts index f87547d566..f8e002b989 100644 --- a/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts +++ b/src/goals/ReviewEntries/Redux/ReviewEntriesActions.ts @@ -161,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); @@ -180,10 +172,19 @@ 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. + // Add/delete audio. for (const audio of addAudio) { newId = await uploadFileFromPronunciation(newId, audio); } From cc1929935c74bbaeb9d1f572a2fe03260d7bfa99 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 8 Dec 2023 18:00:57 -0500 Subject: [PATCH 45/56] Fix _protected bug --- scripts/generate_openapi.py | 1 + src/api/models/pronunciation.ts | 2 +- src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx | 2 +- src/components/Pronunciations/AudioPlayer.tsx | 2 +- src/components/WordCard/index.tsx | 2 +- src/types/word.ts | 6 +++--- 6 files changed, 8 insertions(+), 7 deletions(-) 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/models/pronunciation.ts b/src/api/models/pronunciation.ts index fe1ca16f7f..50657c2cc1 100644 --- a/src/api/models/pronunciation.ts +++ b/src/api/models/pronunciation.ts @@ -35,5 +35,5 @@ export interface Pronunciation { * @type {boolean} * @memberof Pronunciation */ - _protected: boolean; + protected: boolean; } diff --git a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx index 6eac6aae52..bfeec18519 100644 --- a/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx +++ b/src/components/ProjectUsers/SpeakerConsentListItemIcon.tsx @@ -76,7 +76,7 @@ function PlayConsentListItemIcon(props: ConsentIconProps): ReactElement { return ( { if (props.audio.speakerId) { diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index b2170a7f51..b0ac4b0b48 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -79,7 +79,7 @@ export default function WordCard(props: WordCardProps): ReactElement { <> {audio.length > 0 && ( ({ ...a, _protected: true }))} + audio={audio.map((a) => ({ ...a, protected: true }))} deleteAudio={() => {}} playerOnly replaceAudio={() => {}} diff --git a/src/types/word.ts b/src/types/word.ts index f53d6f74f0..b160306620 100644 --- a/src/types/word.ts +++ b/src/types/word.ts @@ -20,11 +20,11 @@ export interface FileWithSpeakerId extends File { } export function newPronunciation(fileName = "", speakerId = ""): Pronunciation { - return { fileName, speakerId, _protected: false }; + return { fileName, speakerId, protected: false }; } /** Returns a copy of the audio array with every entry updated that has: - * - ._protected false; + * - .protected false; * - same .fileName as the update pronunciation; and * - different .speakerId than the update pronunciation. * @@ -34,7 +34,7 @@ export function updateSpeakerInAudio( update: Pronunciation ): Pronunciation[] | undefined { const updatePredicate = (p: Pronunciation): boolean => - !p._protected && + !p.protected && p.fileName === update.fileName && p.speakerId !== update.speakerId; if (audio.findIndex(updatePredicate) === -1) { From 1fd42ad1c4e7385fc0f0daad74f3832d8c36154a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 11 Dec 2023 11:03:06 -0500 Subject: [PATCH 46/56] Add speakers to User Guide --- docs/user_guide/docs/dataEntry.md | 12 ++++++++++-- docs/user_guide/docs/project.md | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/docs/dataEntry.md b/docs/user_guide/docs/dataEntry.md index 78744dbd08..fdcd60cf84 100644 --- a/docs/user_guide/docs/dataEntry.md +++ b/docs/user_guide/docs/dataEntry.md @@ -32,11 +32,19 @@ 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). ## 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..60696b84d0 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} From 1095dc0665cb9a13cfde698215d528b524d51245 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 11 Dec 2023 12:10:43 -0500 Subject: [PATCH 47/56] Protect all imported audio --- .../Controllers/LiftControllerTests.cs | 17 +------ Backend/Models/Word.cs | 2 +- Backend/Services/LiftService.cs | 9 +--- public/locales/en/translation.json | 1 + src/components/Pronunciations/AudioPlayer.tsx | 48 +++++++++++-------- .../Pronunciations/PronunciationsBackend.tsx | 9 ++-- src/components/WordCard/index.tsx | 8 +--- 7 files changed, 39 insertions(+), 55 deletions(-) diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 1bd4852bdf..f5d227b14c 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -524,16 +524,7 @@ public void TestRoundtrip(RoundTripObj roundTripObj) foreach (var audio in word.Audio) { Assert.That(roundTripObj.AudioFiles, Does.Contain(Path.GetFileName(audio.FileName))); - } - - if (word.Audio.Count == 1) - { - // None of the cases have labels on audio, so the audio isn't protected and we can add a speaker. - Assert.That(word.Audio[0].Protected, Is.False); - var speaker = _speakerRepo.Create(new() { Consent = ConsentType.Audio, ProjectId = proj1.Id }).Result; - word.Audio[0].SpeakerId = speaker.Id; - _wordRepo.DeleteAllWords(proj1.Id); - _wordRepo.Create(word); + Assert.That(audio.Protected, Is.True); } } @@ -607,12 +598,6 @@ public void TestRoundtrip(RoundTripObj roundTripObj) { Assert.That(word.Senses[0].Guid.ToString(), Is.EqualTo(roundTripObj.SenseGuid)); } - - if (word.Audio.Count == 1) - { - // The speaker added in Roundtrip Part 1 should have exported as an English label. - Assert.That(word.Audio[0].Protected, Is.True); - } } // Export. diff --git a/Backend/Models/Word.cs b/Backend/Models/Word.cs index 1b44310dbc..92bc6fd124 100644 --- a/Backend/Models/Word.cs +++ b/Backend/Models/Word.cs @@ -264,7 +264,7 @@ public class Pronunciation [Required] public string SpeakerId { get; set; } - /// For any with "en" label that was present on import, to prevent overwriting. + /// For imported audio, to prevent modification or deletion (unless the word is deleted). [Required] public bool Protected { get; set; } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index d2cd6b3609..c103e0fc57 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -853,15 +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 media = pro.Media.First(); - var hasLabel = media.Label is not null && !string.IsNullOrWhiteSpace(media.Label["en"]?.Text); - var audio = new Pronunciation(media.Url) { Protected = hasLabel }; - newWord.Audio.Add(audio); + // Add audio with Protected = true to prevent modifying or deleting imported audio + newWord.Audio.Add(new Pronunciation(pro.Media.First().Url) { Protected = true }); } } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 215f0f75b5..5c4f5803e5 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -465,6 +465,7 @@ "recordTooltip": "Press and hold to record.", "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.", diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 3deb5382d8..e917890e80 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -32,8 +32,8 @@ import { useAppDispatch, useAppSelector } from "types/hooks"; import { themeColors } from "types/theme"; interface PlayerProps { - deleteAudio: (fileName: string) => void; audio: Pronunciation; + deleteAudio?: (fileName: string) => void; onClick?: () => void; pronunciationUrl?: string; size?: "large" | "medium" | "small"; @@ -66,6 +66,7 @@ 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) { @@ -97,7 +98,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { if (props.onClick) { props.onClick(); } - if (event?.shiftKey) { + if (event?.shiftKey && canDeleteAudio) { setDeleteConf(true); } else { togglePlay(); @@ -123,10 +124,12 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { } function handleTouch(event: any): void { - // Temporarily disable context menu since some browsers - // interpret a long-press touch as a right-click. - disableContextMenu(); - 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 { @@ -144,10 +147,13 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { } } - const tooltipTexts = [ - t("pronunciations.playTooltip"), - t("pronunciations.deleteTooltip"), - ]; + 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 })); } @@ -208,22 +214,24 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { )} - { - setDeleteConf(true); - handleMenuOnClose(); - }} - > - - + {canDeleteAudio && ( + { + setDeleteConf(true); + handleMenuOnClose(); + }} + > + + + )} setDeleteConf(false)} - onConfirm={() => props.deleteAudio(props.audio.fileName)} + onConfirm={() => props.deleteAudio!(props.audio.fileName)} buttonIdClose="audio-delete-cancel" buttonIdConfirm="audio-delete-confirm" /> diff --git a/src/components/Pronunciations/PronunciationsBackend.tsx b/src/components/Pronunciations/PronunciationsBackend.tsx index 199b984661..b1d7a15fb0 100644 --- a/src/components/Pronunciations/PronunciationsBackend.tsx +++ b/src/components/Pronunciations/PronunciationsBackend.tsx @@ -11,8 +11,8 @@ interface PronunciationsBackendProps { playerOnly?: boolean; overrideMemo?: boolean; wordId: string; - deleteAudio: (fileName: string) => void; - replaceAudio: (audio: Pronunciation) => void; + deleteAudio?: (fileName: string) => void; + replaceAudio?: (audio: Pronunciation) => void; uploadAudio?: (file: FileWithSpeakerId) => void; } @@ -33,8 +33,9 @@ export function PronunciationsBackend( deleteAudio={props.deleteAudio} key={a.fileName} pronunciationUrl={getAudioUrl(props.wordId, a.fileName)} - updateAudioSpeaker={(id) => - props.replaceAudio({ ...a, speakerId: id ?? "" }) + updateAudioSpeaker={ + props.replaceAudio && + ((id) => props.replaceAudio!({ ...a, speakerId: id ?? "" })) } /> )); diff --git a/src/components/WordCard/index.tsx b/src/components/WordCard/index.tsx index b0ac4b0b48..3d0ca53176 100644 --- a/src/components/WordCard/index.tsx +++ b/src/components/WordCard/index.tsx @@ -78,13 +78,7 @@ export default function WordCard(props: WordCardProps): ReactElement { {full && ( <> {audio.length > 0 && ( - ({ ...a, protected: true }))} - deleteAudio={() => {}} - playerOnly - replaceAudio={() => {}} - wordId={id} - /> + )} {!!note.text && (
From e16e0580a4767ee21f360d03df3939785324474c Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 11 Dec 2023 14:26:26 -0500 Subject: [PATCH 48/56] Tidy --- Backend/Services/LiftService.cs | 2 +- src/components/AppBar/tests/ProjectButtons.test.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index c103e0fc57..03c6fdc103 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -543,7 +543,7 @@ 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, IList pronunciations, string path, + private static async Task AddAudio(LexEntry entry, List pronunciations, string path, string projectId, List projectSpeakers) { foreach (var audio in pronunciations) diff --git a/src/components/AppBar/tests/ProjectButtons.test.tsx b/src/components/AppBar/tests/ProjectButtons.test.tsx index 792233840e..b332c90324 100644 --- a/src/components/AppBar/tests/ProjectButtons.test.tsx +++ b/src/components/AppBar/tests/ProjectButtons.test.tsx @@ -71,7 +71,6 @@ describe("ProjectButtons", () => { }); it("has speaker menu only when in Data Entry or Review Entries", async () => { - mockHasPermission.mockResolvedValueOnce(true); await renderProjectButtons(); expect(testRenderer.root.findAllByType(SpeakerMenu)).toHaveLength(0); From b47fef4f19657bae317479b94fd61a00a52100ee Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 2 Jan 2024 09:23:26 -0500 Subject: [PATCH 49/56] Expand AudioController tests --- .../Controllers/AudioControllerTests.cs | 89 ++++++++++++++----- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index bc20c2397d..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,58 +49,97 @@ 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"; // file in Backend.Tests/Assets/ - 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()); + } + + [Test] + public void TestUploadAudioFile() + { + // `_fileUpload` contains the file stream and the name of the file. + _ = _audioController.UploadAudioFile(_projId, _wordId, "speakerId", _fileUpload).Result; + + var foundWord = _wordRepo.GetWord(_projId, _wordId).Result; + Assert.That(foundWord?.Audio, Is.Not.Null); + } - // 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 TestDownloadAudioFileInvalidArguments() + { + var result = _audioController.DownloadAudioFile("invalid/projId", "wordId", "fileName"); + Assert.That(result, Is.TypeOf()); - var word = _wordRepo.Create(Util.RandomWord(_projId)).Result; + result = _audioController.DownloadAudioFile("projId", "invalid/wordId", "fileName"); + Assert.That(result, Is.TypeOf()); - // `fileUpload` contains the file stream and the name of the file. - _ = _audioController.UploadAudioFile(_projId, word.Id, "", fileUpload).Result; + result = _audioController.DownloadAudioFile("projId", "wordId", "invalid/fileName"); + Assert.That(result, Is.TypeOf()); + } - var foundWord = _wordRepo.GetWord(_projId, word.Id).Result; - Assert.That(foundWord?.Audio, Is.Not.Null); + [Test] + public void TestDownloadAudioFileNoFile() + { + var result = _audioController.DownloadAudioFile("projId", "wordId", "fileName"); + Assert.That(result, Is.TypeOf()); } [Test] public void DeleteAudio() { - // Fill test database + // Refill test database + _wordRepo.DeleteAllWords(_projId); var origWord = Util.RandomWord(_projId); var fileName = "a.wav"; origWord.Audio.Add(new Pronunciation(fileName)); From 7d3869c1b7bef7fbb126467487b86c044f02bbaa Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 2 Jan 2024 13:43:57 -0500 Subject: [PATCH 50/56] Add py script for db update --- Backend/Models/Word.cs | 3 ++ maintenance/scripts/db_update_audio_type.py | 59 +++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 maintenance/scripts/db_update_audio_type.py diff --git a/Backend/Models/Word.cs b/Backend/Models/Word.cs index 92bc6fd124..40727a9c41 100644 --- a/Backend/Models/Word.cs +++ b/Backend/Models/Word.cs @@ -258,14 +258,17 @@ 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() diff --git a/maintenance/scripts/db_update_audio_type.py b/maintenance/scripts/db_update_audio_type.py new file mode 100644 index 0000000000..98e40d111d --- /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): + newAudio = [] + for fileName in word["audio"]: + if type(fileName) == "string": + newAudio.append({"speakerId": "", "protected": False, "fileName": fileName}) + else: + newAudio.append(fileName) + word["audio"] = newAudio + 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() From be3faa365dc8f597e22d0a222e982219e13a4293 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 2 Jan 2024 13:54:49 -0500 Subject: [PATCH 51/56] Make tox earn its keep --- maintenance/scripts/db_update_audio_type.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/maintenance/scripts/db_update_audio_type.py b/maintenance/scripts/db_update_audio_type.py index 98e40d111d..bf3a3d2972 100644 --- a/maintenance/scripts/db_update_audio_type.py +++ b/maintenance/scripts/db_update_audio_type.py @@ -35,20 +35,20 @@ def main() -> None: 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): - newAudio = [] - for fileName in word["audio"]: - if type(fileName) == "string": - newAudio.append({"speakerId": "", "protected": False, "fileName": fileName}) + new_audio = [] + for aud in word["audio"]: + if isinstance(aud, str): + new_audio.append({"speakerId": "", "protected": False, "fileName": aud}) else: - newAudio.append(fileName) - word["audio"] = newAudio + 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(): From fe76805f8b7e2070623ca75ebf05464176b2c485 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 3 Jan 2024 14:54:19 -0500 Subject: [PATCH 52/56] Remove old one-shot script --- .../scripts/db_update_sem_dom_in_senses.py | 146 ------------------ 1 file changed, 146 deletions(-) delete mode 100755 maintenance/scripts/db_update_sem_dom_in_senses.py 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() From d170691833d8f70f6b9ab7cb80f0e375facdb2f8 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 4 Jan 2024 14:56:55 -0500 Subject: [PATCH 53/56] Update db script: protect audio; add execute permission --- maintenance/scripts/db_update_audio_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 maintenance/scripts/db_update_audio_type.py diff --git a/maintenance/scripts/db_update_audio_type.py b/maintenance/scripts/db_update_audio_type.py old mode 100644 new mode 100755 index bf3a3d2972..5b2ea027e9 --- a/maintenance/scripts/db_update_audio_type.py +++ b/maintenance/scripts/db_update_audio_type.py @@ -45,7 +45,7 @@ def main() -> None: new_audio = [] for aud in word["audio"]: if isinstance(aud, str): - new_audio.append({"speakerId": "", "protected": False, "fileName": aud}) + new_audio.append({"speakerId": "", "protected": True, "fileName": aud}) else: new_audio.append(aud) word["audio"] = new_audio From f4b9e6d5fcbc10b4b4fa7e253b45a8f470824ca4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 4 Jan 2024 15:24:37 -0500 Subject: [PATCH 54/56] Tidy tests and docs --- Backend.Tests/Controllers/LiftControllerTests.cs | 3 +-- docs/user_guide/docs/dataEntry.md | 4 ++++ docs/user_guide/docs/project.md | 8 ++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index f5d227b14c..f5ceda8534 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -588,11 +588,10 @@ public void TestRoundtrip(RoundTripObj roundTripObj) allWords = _wordRepo.GetAllWords(proj2.Id).Result; Assert.That(allWords, Has.Count.EqualTo(roundTripObj.NumOfWords)); - // We are currently only testing guids and audio on the single-entry data sets. + // We are currently only testing guids on the single-entry data sets. if (roundTripObj.EntryGuid != "" && allWords.Count == 1) { var word = allWords[0]; - Assert.That(word.Guid.ToString(), Is.EqualTo(roundTripObj.EntryGuid)); if (roundTripObj.SenseGuid != "") { diff --git a/docs/user_guide/docs/dataEntry.md b/docs/user_guide/docs/dataEntry.md index fdcd60cf84..2445959e02 100644 --- a/docs/user_guide/docs/dataEntry.md +++ b/docs/user_guide/docs/dataEntry.md @@ -46,6 +46,10 @@ speaker will be automatically associated with every audio recording until you lo 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} If you submit a new entry with identical vernacular form and gloss to an existing entry, that entry will be updated diff --git a/docs/user_guide/docs/project.md b/docs/user_guide/docs/project.md index 60696b84d0..119a73484d 100644 --- a/docs/user_guide/docs/project.md +++ b/docs/user_guide/docs/project.md @@ -166,6 +166,14 @@ 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. + +!!! note "Note" + + Speaker consent files are exported, but are not imported into FieldWorks. + ### Workshop Schedule {#workshop-schedule} ![Workshop Schedule](images/projectSettings5Sched.png){width=750 .center} From 5382b9de685d73def44bd42c589d42d00d78d9be Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 4 Jan 2024 15:36:01 -0500 Subject: [PATCH 55/56] Add speaker export details to docs --- docs/user_guide/docs/project.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/docs/project.md b/docs/user_guide/docs/project.md index 119a73484d..49b808796f 100644 --- a/docs/user_guide/docs/project.md +++ b/docs/user_guide/docs/project.md @@ -170,9 +170,11 @@ project name with a timestamp affixed. Project settings, project users, and word flags are not exported. -!!! note "Note" +#### Export pronunciation speakers - Speaker consent files are exported, but are not imported into FieldWorks. +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} From ed821be5181f0d1f4c4c35d3d467f74db8910882 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 4 Jan 2024 16:27:52 -0500 Subject: [PATCH 56/56] Add icon spacer to avoid close button overlapping title --- src/components/Dialogs/ViewImageDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Dialogs/ViewImageDialog.tsx b/src/components/Dialogs/ViewImageDialog.tsx index d5537065b0..e6d6e8ea1c 100644 --- a/src/components/Dialogs/ViewImageDialog.tsx +++ b/src/components/Dialogs/ViewImageDialog.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogContent, DialogTitle, Grid } from "@mui/material"; +import { Dialog, DialogContent, DialogTitle, Grid, Icon } from "@mui/material"; import { ReactElement } from "react"; import { CloseButton, DeleteButtonWithDialog } from "components/Buttons"; @@ -27,6 +27,7 @@ export default function ViewImageDialog( {props.title} +