Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce unique speaker names; Use name on export #3018

Merged
merged 20 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions Backend.Tests/Controllers/LiftControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,63 @@ public async Task TestDeletedWordsExportToLift()
Assert.That(notFoundResult, Is.TypeOf<NotFoundObjectResult>());
}

[Test]
public async Task TestExportConsentFileWithSpeakerName()
{
// Add word so there's something to export
await _wordRepo.Create(Util.RandomWord(_projId));

// Add speakers to project with names that will collide when sanitized
await _speakerRepo.Create(new Speaker { Name = "No consent!", ProjectId = _projId });

var nameNoExt = "Underscored_name";
var speakerNoExt = await _speakerRepo.Create(
new Speaker { Name = nameNoExt, ProjectId = _projId, Consent = ConsentType.Image });
var speakerNoExt1 = await _speakerRepo.Create(
new Speaker { Name = "Underscored name", ProjectId = _projId, Consent = ConsentType.Image });
var speakerNoExt2 = await _speakerRepo.Create(
new Speaker { Name = "Underscored_náme", ProjectId = _projId, Consent = ConsentType.Image });

var nameExts = "Imagination";
var speakerExts = await _speakerRepo.Create(
new Speaker { Name = nameExts, ProjectId = _projId, Consent = ConsentType.Image });
var speakerExts1 = await _speakerRepo.Create(
new Speaker { Name = "Imágination", ProjectId = _projId, Consent = ConsentType.Image });

// Create mock consent files tied to speaker ids
var pathNoExt = FileStorage.GenerateConsentFilePath(speakerNoExt.Id);
var pathNoExt1 = FileStorage.GenerateConsentFilePath(speakerNoExt1.Id);
var pathNoExt2 = FileStorage.GenerateConsentFilePath(speakerNoExt2.Id);
var expectedFileNames = new List<string> { nameNoExt, $"{nameNoExt}1", $"{nameNoExt}2" };

var ext = ".png";
var pathExt = FileStorage.GenerateConsentFilePath(speakerExts.Id, ext);
var pathExt1 = FileStorage.GenerateConsentFilePath(speakerExts1.Id, ext);
expectedFileNames.AddRange(new List<string> { $"{nameExts}{ext}", $"{nameExts}1{ext}" });

var mockFiles = new List<string> { pathNoExt, pathNoExt1, pathNoExt2, pathExt, pathExt1 };
mockFiles.ForEach(path => File.Create(path).Dispose());

// Export the project
var exportedFilePath = await _liftController.CreateLiftExport(_projId);
var exportedDirectory = FileOperations.ExtractZipFile(exportedFilePath, null);
var exportedProjDir = Directory.GetDirectories(exportedDirectory).First();

// Verify all consent files were copied over with speaker names
var consentFiles = Directory.GetFiles(Path.Combine(exportedProjDir, "consent"));
var consentFileNames = consentFiles.Select(path => Path.GetFileName(path)).ToList();
Assert.That(consentFileNames, Has.Count.EqualTo(expectedFileNames.Count));
foreach (var file in expectedFileNames)
{
Assert.That(consentFileNames.Contains(file));
};

// Delete everything
mockFiles.ForEach(path => File.Delete(path));
File.Delete(exportedFilePath);
Directory.Delete(exportedDirectory, true);
}

private static RoundTripObj[] _roundTripCases =
{
new("Gusillaay.zip", "gsl-Qaaa-x-orth", new List<string>(), 8045),
Expand Down
33 changes: 30 additions & 3 deletions Backend.Tests/Controllers/SpeakerControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ public void TestCreateSpeakerUnauthorized()
Assert.That(result, Is.InstanceOf<ForbidResult>());
}

[Test]
public void TestCreateEmptyName()
{
var result = _speakerController.CreateSpeaker(ProjId, " \n\t").Result;
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
}

[Test]
public void TestCreateNameTaken()
{
var oldCount = _speakerRepo.GetAllSpeakers(ProjId).Result.Count;
var result = _speakerController.CreateSpeaker(ProjId, $"\n{Name} ").Result;
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
Assert.That(_speakerRepo.GetAllSpeakers(ProjId).Result, Has.Count.EqualTo(oldCount));
}

[Test]
public void TestCreateSpeaker()
{
Expand Down Expand Up @@ -212,10 +228,21 @@ public void TestUpdateSpeakerNameNoSpeaker()
}

[Test]
public void TestUpdateSpeakerNameSameName()
public void TestUpdateSpeakerNameEmptyName()
{
var result = _speakerController.UpdateSpeakerName(ProjId, _speaker.Id, Name).Result;
Assert.That(((ObjectResult)result).StatusCode, Is.EqualTo(StatusCodes.Status304NotModified));
var result = _speakerController.UpdateSpeakerName(ProjId, _speaker.Id, " \n\t").Result;
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
}

[Test]
public void TestUpdateSpeakerNameNameTaken()
{
var result = _speakerController.UpdateSpeakerName(ProjId, _speaker.Id, $" {Name}\t").Result;
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());

var idOfNewSpeaker = ((ObjectResult)_speakerController.CreateSpeaker(ProjId, "Ms. Other").Result).Value as string;
result = _speakerController.UpdateSpeakerName(ProjId, idOfNewSpeaker!, $"\t{Name}\n").Result;
Assert.That(result, Is.InstanceOf<BadRequestObjectResult>());
}

[Test]
Expand Down
3 changes: 2 additions & 1 deletion Backend.Tests/Helper/SanitizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,12 @@ public void TestInvalidFileNames(string fileName)
private static List<List<string>> _namesUnfriendlyFriendly = new()
{
new List<string> { "A1phaNum3ricN0Change", "A1phaNum3ricN0Change" },
new List<string> { "こんにちは", "こんにちは" },
new List<string> { "RémöveOrRèpláceÄccênts", "RemoveOrReplaceAccents" },
new List<string> { "алтайча", "алтаича" },
new List<string> { "math+and=currency$to<dash", "math-and-currency-to-dash" },
new List<string> { "make spaces underscores", "make_spaces_underscores" },
new List<string> { "(){}[]", "()()()" },
new List<string> { "こんにちは", "-----" },
new List<string> { "⁇⸘¡", ",,," }
};
[TestCaseSource(nameof(_namesUnfriendlyFriendly))]
Expand Down
5 changes: 5 additions & 0 deletions Backend.Tests/Mocks/SpeakerRepositoryMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,10 @@ public Task<ResultOfUpdate> Update(string speakerId, Speaker speaker)
_speakers.Add(speaker.Clone());
return Task.FromResult(ResultOfUpdate.Updated);
}

public Task<bool> IsSpeakerNameInProject(string projectId, string name)
{
return Task.FromResult(_speakers.Any(s => s.ProjectId == projectId && s.Name == name));
}
}
}
31 changes: 31 additions & 0 deletions Backend/Controllers/SpeakerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ public async Task<IActionResult> GetSpeaker(string projectId, string speakerId)
return Ok(speaker);
}

/// <summary> Checks if given speaker name is valid for the project with given id. </summary>
/// <returns> null if valid; a BadRequestObjectResult if invalid. </returns>
private async Task<IActionResult?> CheckSpeakerName(string projectId, string name)
{
if (string.IsNullOrEmpty(name))
{
return BadRequest("projectSettings.speaker.nameEmpty");
}
if (await _speakerRepo.IsSpeakerNameInProject(projectId, name))
{
return BadRequest("projectSettings.speaker.nameTaken");
}
return null;
}

/// <summary> Creates a <see cref="Speaker"/> for the specified projectId </summary>
/// <returns> Id of created Speaker </returns>
[HttpGet("create/{name}", Name = "CreateSpeaker")]
Expand All @@ -92,6 +107,14 @@ public async Task<IActionResult> CreateSpeaker(string projectId, string name)
return Forbid();
}

// Ensure the new name is valid
name = name.Trim();
var nameError = await CheckSpeakerName(projectId, name);
if (nameError is not null)
{
return nameError;
}

// Create speaker and return id
var speaker = new Speaker { Name = name, ProjectId = projectId };
return Ok((await _speakerRepo.Create(speaker)).Id);
Expand Down Expand Up @@ -188,6 +211,14 @@ public async Task<IActionResult> UpdateSpeakerName(string projectId, string spea
return NotFound(speakerId);
}

// Ensure the new name is valid
name = name.Trim();
var nameError = await CheckSpeakerName(projectId, name);
if (nameError is not null)
{
return nameError;
}

// Update name and return result with id
speaker.Name = name;
return await _speakerRepo.Update(speakerId, speaker) switch
Expand Down
10 changes: 10 additions & 0 deletions Backend/Helper/FileOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,15 @@ public static List<string> FindFilesWithExtension(string dir, string ext, bool r
}
return files;
}

/// <summary> Like Path.ChangeExtension, but doesn't add a . for empty-string extension. </summary>
public static string ChangeExtension(string path, string? extension)
{
if (extension == "")
{
extension = null;
}
return Path.ChangeExtension(path, extension);
}
}
}
2 changes: 1 addition & 1 deletion Backend/Helper/FileStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public static string GenerateAvatarFilePath(string userId)
/// <exception cref="InvalidIdException"> Throws when id invalid. </exception>
public static string GenerateConsentFilePath(string speakerId, string? extension = null)
{
var fileName = Path.ChangeExtension(Sanitization.SanitizeId(speakerId), extension);
var fileName = FileOperations.ChangeExtension(Sanitization.SanitizeId(speakerId), extension);
return GenerateFilePath(ConsentDir, fileName);
}

Expand Down
4 changes: 2 additions & 2 deletions Backend/Helper/Sanitization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static string SanitizeFileName(string fileName)

/// <summary>
/// Convert a string (e.g., a project name), into one friendly to use in a path.
/// Uses alphanumeric and '-' '_' ',' '(' ')'.
/// Uses international alphanumeric and '-' '_' ',' '(' ')'.
/// </summary>
/// <returns> Converted string, unless length 0, then returns fallback. </returns>
public static string MakeFriendlyForPath(string name, string fallback = "")
Expand All @@ -87,13 +87,13 @@ public static string MakeFriendlyForPath(string name, string fallback = "")
{
case UnicodeCategory.LowercaseLetter:
case UnicodeCategory.UppercaseLetter:
case UnicodeCategory.OtherLetter:
case UnicodeCategory.DecimalDigitNumber:
stringBuilder.Append(c);
break;
case UnicodeCategory.DashPunctuation:
case UnicodeCategory.CurrencySymbol:
case UnicodeCategory.MathSymbol:
case UnicodeCategory.OtherLetter:
case UnicodeCategory.OtherSymbol:
stringBuilder.Append('-');
break;
Expand Down
1 change: 1 addition & 0 deletions Backend/Interfaces/ISpeakerRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ public interface ISpeakerRepository
Task<bool> Delete(string projectId, string speakerId);
Task<bool> DeleteAllSpeakers(string projectId);
Task<ResultOfUpdate> Update(string speakerId, Speaker speaker);
Task<bool> IsSpeakerNameInProject(string projectId, string name);
}
}
15 changes: 10 additions & 5 deletions Backend/Repositories/SpeakerRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ public async Task<bool> DeleteAllSpeakers(string projectId)
public async Task<Speaker?> GetSpeaker(string projectId, string speakerId)
{
var filterDef = new FilterDefinitionBuilder<Speaker>();
var filter = filterDef.And(filterDef.Eq(
x => x.ProjectId, projectId), filterDef.Eq(x => x.Id, speakerId));
var filter = filterDef.And(filterDef.Eq(x => x.ProjectId, projectId), filterDef.Eq(x => x.Id, speakerId));

var speakerList = await _speakerDatabase.Speakers.FindAsync(filter);
try
Expand All @@ -64,9 +63,7 @@ public async Task<Speaker> Create(Speaker speaker)
public async Task<bool> Delete(string projectId, string speakerId)
{
var filterDef = new FilterDefinitionBuilder<Speaker>();
var filter = filterDef.And(
filterDef.Eq(x => x.ProjectId, projectId),
filterDef.Eq(x => x.Id, speakerId));
var filter = filterDef.And(filterDef.Eq(x => x.ProjectId, projectId), filterDef.Eq(x => x.Id, speakerId));

return (await _speakerDatabase.Speakers.DeleteOneAsync(filter)).DeletedCount > 0;
}
Expand All @@ -88,5 +85,13 @@ public async Task<ResultOfUpdate> Update(string speakerId, Speaker speaker)
? ResultOfUpdate.Updated
: ResultOfUpdate.NoChange;
}

/// <summary> Check if <see cref="Speaker"/> with specified name is already in project </summary>
public async Task<bool> IsSpeakerNameInProject(string projectId, string name)
{
var filterDef = new FilterDefinitionBuilder<Speaker>();
var filter = filterDef.And(filterDef.Eq(x => x.ProjectId, projectId), filterDef.Eq(x => x.Name, name));
return await _speakerDatabase.Speakers.CountDocumentsAsync(filter) > 0;
}
}
}
69 changes: 43 additions & 26 deletions Backend/Services/LiftService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,39 @@ public async Task<string> LiftExport(
// Add consent files to export directory
foreach (var speaker in projSpeakers)
{
if (speaker.Consent != ConsentType.None)
if (speaker.Consent == ConsentType.None)
{
var src = FileStorage.GetConsentFilePath(speaker.Id);
if (src is not null)
{
var dest = Path.Combine(consentDir, Path.GetFileName(src));
if (Path.GetExtension(dest).Equals(".webm", StringComparison.OrdinalIgnoreCase))
{
dest = Path.ChangeExtension(dest, ".wav");
await FFmpeg.Conversions.New().Start($"-y -i \"{src}\" \"{dest}\"");
}
else
{
File.Copy(src, dest);
}
}
continue;
}

var src = FileStorage.GetConsentFilePath(speaker.Id);
if (src is null || !File.Exists(src))
{
continue;
};

var safeName = Sanitization.MakeFriendlyForPath(speaker.Name);
var fileName = safeName == "" ? Path.GetFileNameWithoutExtension(src) : safeName;
var fileExt = Path.GetExtension(src);
var convertToWav = fileExt.Equals(".webm", StringComparison.OrdinalIgnoreCase);
fileExt = convertToWav ? ".wav" : fileExt;
var dest = FileOperations.ChangeExtension(Path.Combine(consentDir, fileName), fileExt);

// Prevent collisions resulting from name sanitization
var duplicate = 0;
while (File.Exists(dest))
{
duplicate++;
dest = FileOperations.ChangeExtension(Path.Combine(consentDir, $"{fileName}{duplicate}"), fileExt);
}

if (convertToWav)
{
await FFmpeg.Conversions.New().Start($"-y -i \"{src}\" \"{dest}\"");
}
else
{
File.Copy(src, dest);
}
}

Expand Down Expand Up @@ -541,11 +558,13 @@ private static async Task AddAudio(LexEntry entry, List<Pronunciation> pronuncia
{
foreach (var audio in pronunciations)
{
var lexPhonetic = new LexPhonetic();
var src = FileStorage.GenerateAudioFilePath(projectId, audio.FileName);
var dest = Path.Combine(path, audio.FileName);
if (!File.Exists(src))
{
continue;
};

if (!File.Exists(src)) continue;
var dest = Path.Combine(path, audio.FileName);
if (Path.GetExtension(dest).Equals(".webm", StringComparison.OrdinalIgnoreCase))
{
dest = Path.ChangeExtension(dest, ".wav");
Expand All @@ -556,16 +575,14 @@ private static async Task AddAudio(LexEntry entry, List<Pronunciation> pronuncia
File.Copy(src, dest, true);
}

var lexPhonetic = new LexPhonetic();
lexPhonetic.MergeIn(MultiText.Create(new LiftMultiText { { "href", dest } }));
// If audio has speaker, include speaker info as a pronunciation label
if (!audio.Protected && !string.IsNullOrEmpty(audio.SpeakerId))
// If audio has speaker, include speaker name as a pronunciation label
var speaker = projectSpeakers.Find(s => s.Id == audio.SpeakerId);
if (speaker is not null)
{
var speaker = projectSpeakers.Find(s => s.Id == audio.SpeakerId);
if (speaker is not null)
{
var text = new LiftMultiText { { "en", $"Speaker #{speaker.Id}: {speaker.Name}" } };
lexPhonetic.MergeIn(MultiText.Create(text));
}
var text = new LiftMultiText { { "en", $"Speaker: {speaker.Name}" } };
lexPhonetic.MergeIn(MultiText.Create(text));
}
entry.Pronunciations.Add(lexPhonetic);
}
Expand Down
2 changes: 2 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@
"label": "Manage speakers",
"add": "Add a speaker",
"enterName": "Enter the name of a new speaker",
"nameEmpty": "Speaker name is empty",
"nameTaken": "Speaker name is already taken in this project",
"delete": "Delete this speaker",
"edit": "Edit speaker's name",
"consent": {
Expand Down
Loading
Loading