From 6745964d0c4362fe54d47468f660b42a469ebcc5 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 11 Feb 2026 17:29:54 -0500 Subject: [PATCH 01/14] Refactor Repo.GetFrontier and Service.Update --- .../Controllers/AudioControllerTests.cs | 23 ++++------ .../Controllers/LiftControllerTests.cs | 4 +- .../Controllers/WordControllerTests.cs | 12 ++--- Backend.Tests/Mocks/WordRepositoryMock.cs | 17 ++++--- Backend.Tests/Services/MergeServiceTests.cs | 18 ++++---- Backend.Tests/Services/WordServiceTests.cs | 44 +++++++++++++------ Backend/Controllers/AudioController.cs | 14 +++--- Backend/Controllers/WordController.cs | 37 ++++++++-------- Backend/Interfaces/IWordRepository.cs | 3 +- Backend/Interfaces/IWordService.cs | 4 +- Backend/Repositories/WordRepository.cs | 14 +++++- Backend/Services/LiftService.cs | 2 +- Backend/Services/MergeService.cs | 8 ++-- Backend/Services/StatisticsService.cs | 8 ++-- Backend/Services/WordService.cs | 24 +++++----- 15 files changed, 129 insertions(+), 103 deletions(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 2ceb7f9087..19061a134b 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -170,31 +170,24 @@ public void TestDeleteAudioFile() { // Refill test database _wordRepo.DeleteAllWords(_projId); + _wordRepo.DeleteAllFrontierWords(_projId); var origWord = Util.RandomWord(_projId); const string fileName = "a.wav"; origWord.Audio.Add(new Pronunciation(fileName)); - var wordId = _wordRepo.Create(origWord).Result.Id; + var oldId = _wordRepo.Create(origWord).Result.Id; // Test delete function - _ = _audioController.DeleteAudioFile(_projId, wordId, fileName).Result; + var result = _audioController.DeleteAudioFile(_projId, oldId, fileName).Result as OkObjectResult; - // 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 audio files - Assert.That(frontier[0].Audio, Has.Count.EqualTo(0)); - - // Test the frontier - Assert.That(_wordRepo.GetFrontier(_projId).Result, Has.Count.EqualTo(1)); + // Ensure returned id in different + var newId = result?.Value as string; + Assert.That(newId, Is.Not.Null.Or.EqualTo(oldId)); // Ensure the word with deleted audio is in the frontier + var frontier = _wordRepo.GetAllFrontier(_projId).Result; Assert.That(frontier, Has.Count.EqualTo(1)); - Assert.That(frontier[0].Id, Is.Not.EqualTo(wordId)); + Assert.That(frontier[0].Id, Is.EqualTo(newId)); 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 386d06bbc3..0770c11de7 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -413,10 +413,10 @@ public async Task TestDeletedWordsExportToLift() // Create untouched word. await _wordRepo.Create(secondWord); - word.Id = ""; + word.Id = wordToUpdate.Id; word.Vernacular = "updated"; - await _wordService.Update(_projId, UserId, wordToUpdate.Id, word); + await _wordService.Update(UserId, word); await _wordService.DeleteFrontierWord(_projId, UserId, wordToDelete.Id); _liftService.SetExportInProgress(UserId, ExportId); diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 5fd64fb7a7..d9fda8eab8 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -71,7 +71,7 @@ public async Task TestDeleteFrontierWord() w.Id == wordToDelete.Id || w.Id == otherWord.Id || w.Accessibility == Status.Deleted)); - var updatedFrontier = await _wordRepo.GetFrontier(ProjId); + var updatedFrontier = await _wordRepo.GetAllFrontier(ProjId); Assert.That(updatedFrontier, Has.Count.EqualTo(1)); Assert.That(updatedFrontier.First().Id, Is.EqualTo(otherWord.Id)); } @@ -248,7 +248,7 @@ public async Task TestRevertWords() }); var reverted = (Dictionary)((OkObjectResult)result).Value!; Assert.That(reverted, Has.Count.EqualTo(1)); - var frontierIds = (await _wordRepo.GetFrontier(ProjId)).Select(w => w.Id).ToList(); + var frontierIds = (await _wordRepo.GetAllFrontier(ProjId)).Select(w => w.Id).ToList(); Assert.That(frontierIds, Has.Count.EqualTo(2)); Assert.That(frontierIds, Does.Contain(frontierWord1.Id)); Assert.That(frontierIds, Does.Contain(reverted[frontierWord0.Id])); @@ -317,7 +317,7 @@ public async Task TestCreateWord() var allWords = await _wordRepo.GetAllWords(ProjId); Assert.That(allWords[0], Is.EqualTo(word).UsingPropertiesComparer()); - var frontier = await _wordRepo.GetFrontier(ProjId); + var frontier = await _wordRepo.GetAllFrontier(ProjId); Assert.That(frontier[0], Is.EqualTo(word).UsingPropertiesComparer()); } @@ -350,7 +350,7 @@ public async Task TestUpdateWord() Assert.That(allWords, Does.Contain(origWord).UsingPropertiesComparer()); Assert.That(allWords, Does.Contain(finalWord).UsingPropertiesComparer()); - var frontier = await _wordRepo.GetFrontier(ProjId); + var frontier = await _wordRepo.GetAllFrontier(ProjId); Assert.That(frontier, Has.Count.EqualTo(1)); Assert.That(frontier, Does.Contain(finalWord).UsingPropertiesComparer()); } @@ -381,14 +381,14 @@ public async Task TestRestoreWord() await _wordRepo.DeleteFrontier(ProjId, word.Id); Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); - Assert.That(await _wordRepo.GetFrontier(ProjId), Is.Empty); + Assert.That(await _wordRepo.GetAllFrontier(ProjId), Is.Empty); var result = await _wordController.RestoreWord(ProjId, word.Id); Assert.That(result, Is.InstanceOf()); Assert.That(((OkObjectResult)result).Value, Is.True); Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); - Assert.That(await _wordRepo.GetFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); + Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); } [Test] diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index d05a6f7e7d..36f8edad4e 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -13,7 +13,7 @@ internal sealed class WordRepositoryMock : IWordRepository private readonly List _words = []; private readonly List _frontier = []; - private Task? _getFrontierDelay; + private Task? _getAllFrontierDelay; private int _getFrontierCallCount; /// @@ -22,7 +22,7 @@ internal sealed class WordRepositoryMock : IWordRepository /// public void SetGetFrontierDelay(Task delay) { - _getFrontierDelay = delay; + _getAllFrontierDelay = delay; _getFrontierCallCount = 0; } @@ -99,21 +99,28 @@ public Task GetFrontierCount(string projectId) return Task.FromResult(_frontier.Count(w => w.ProjectId == projectId)); } - public async Task> GetFrontier(string projectId) + public async Task> GetAllFrontier(string projectId) { - if (_getFrontierDelay is not null) + if (_getAllFrontierDelay is not null) { var callCount = Interlocked.Increment(ref _getFrontierCallCount); if (callCount == 1) { // First call waits for the signal - await _getFrontierDelay; + await _getAllFrontierDelay; } } return _frontier.Where(w => w.ProjectId == projectId).Select(w => w.Clone()).ToList(); } + public Task GetFrontier(string projectId, string wordId, string? audioFileName = null) + { + var word = _frontier.Find(w => w.ProjectId == projectId && w.Id == wordId && + (string.IsNullOrEmpty(audioFileName) || w.Audio.Any(a => a.FileName == audioFileName))); + return Task.FromResult(word); + } + public Task> GetFrontierWithVernacular(string projectId, string vernacular) { return Task.FromResult(_frontier.Where( diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index 34bfbf55ab..8b47d351f0 100644 --- a/Backend.Tests/Services/MergeServiceTests.cs +++ b/Backend.Tests/Services/MergeServiceTests.cs @@ -55,7 +55,7 @@ public void MergeWordsOneChildTest() Util.AssertEqualWordContent(newWords.First(), thisWord, true); // Check that the only word in the frontier is the new word - var frontier = _wordRepo.GetFrontier(ProjId).Result; + var frontier = _wordRepo.GetAllFrontier(ProjId).Result; Assert.That(frontier, Has.Count.EqualTo(1)); Assert.That(frontier.First(), Is.EqualTo(newWords.First()).UsingPropertiesComparer()); @@ -81,7 +81,7 @@ public void MergeWordsDeleteTest() var newWords = _mergeService.Merge(ProjId, UserId, [mergeObject]).Result; // There should be no word added and no words left in the frontier. Assert.That(newWords, Is.Empty); - var frontier = _wordRepo.GetFrontier(ProjId).Result; + var frontier = _wordRepo.GetAllFrontier(ProjId).Result; Assert.That(frontier, Is.Empty); } @@ -105,7 +105,7 @@ public void MergeWordsMultiChildTest() Assert.That(_wordRepo.GetWord(ProjId, id).Result, Is.Not.Null); mergeWords.Children.Add(new MergeSourceWord { SrcWordId = id }); } - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(numberOfChildren)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(numberOfChildren)); var mergeWordsList = new List { mergeWords }; var newWords = _mergeService.Merge(ProjId, UserId, mergeWordsList).Result; @@ -120,7 +120,7 @@ public void MergeWordsMultiChildTest() // Confirm that parent added to repo and children not in frontier. Assert.That(_wordRepo.GetWord(ProjId, dbParent.Id).Result, Is.Not.Null); - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(1)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1)); } [Test] @@ -134,7 +134,7 @@ public void MergeWordsMultipleTest() Assert.That(newWords, Has.Count.EqualTo(wordCount)); Assert.That(newWords.First().Id, Is.Not.EqualTo(newWords.Last().Id)); - var frontier = _wordRepo.GetFrontier(ProjId).Result; + var frontier = _wordRepo.GetAllFrontier(ProjId).Result; Assert.That(frontier, Has.Count.EqualTo(wordCount)); Assert.That(frontier.First().Id, Is.Not.EqualTo(frontier.Last().Id)); Assert.That(newWords, Does.Contain(frontier.First()).UsingPropertiesComparer()); @@ -165,7 +165,7 @@ public void UndoMergeOneChildTest() var undo = _mergeService.UndoMerge(ProjId, UserId, mergedWord).Result; Assert.That(undo, Is.True); - var frontierWords = _wordRepo.GetFrontier(ProjId).Result; + var frontierWords = _wordRepo.GetAllFrontier(ProjId).Result; var frontierWordIds = frontierWords.Select(word => word.Id).ToList(); Assert.That(frontierWords, Has.Count.EqualTo(1)); @@ -185,12 +185,12 @@ public void UndoMergeMultiChildTest() Assert.That(_wordRepo.GetWord(ProjId, id).Result, Is.Not.Null); mergeWords.Children.Add(new MergeSourceWord { SrcWordId = id }); } - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(numberOfChildren)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(numberOfChildren)); var mergeWordsList = new List { mergeWords }; var newWords = _mergeService.Merge(ProjId, UserId, mergeWordsList).Result; - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(1)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1)); var childIds = mergeWords.Children.Select(word => word.SrcWordId).ToList(); var parentIds = new List { newWords[0].Id }; @@ -198,7 +198,7 @@ public void UndoMergeMultiChildTest() var undo = _mergeService.UndoMerge(ProjId, UserId, mergedWord).Result; Assert.That(undo, Is.True); - var frontierWords = _wordRepo.GetFrontier(ProjId).Result; + var frontierWords = _wordRepo.GetAllFrontier(ProjId).Result; var frontierWordIds = frontierWords.Select(word => word.Id).ToList(); Assert.That(frontierWords, Has.Count.EqualTo(numberOfChildren)); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 6938979699..1dd05a812a 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -43,7 +43,7 @@ public void TestCreateMultipleWords() { _ = _wordService.Create(UserId, [new() { ProjectId = ProjId }, new() { ProjectId = ProjId }]).Result; Assert.That(_wordRepo.GetAllWords(ProjId).Result, Has.Count.EqualTo(2)); - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(2)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2)); } [Test] @@ -72,17 +72,33 @@ public void TestDeleteAudio() var fileName = "audio.mp3"; var wordInFrontier = _wordRepo.Create( new Word() { Audio = [new() { FileName = fileName }], ProjectId = ProjId }).Result; - var result = _wordService.DeleteAudio(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); + var oldId = wordInFrontier.Id; + + var newId = _wordService.DeleteAudio(ProjId, UserId, oldId, fileName).Result; + Assert.That(newId, Is.Not.Null.Or.EqualTo(oldId)); + + // Original word persists + var allWords = _wordRepo.GetAllWords(ProjId).Result; + Assert.That(allWords, Has.Count.EqualTo(2)); + Assert.That(allWords.Find(w => w.Id == newId), Is.Not.Null); + var oldWord = allWords.Find(w => w.Id == oldId); + Assert.That(oldWord, Is.Not.Null); + Assert.That(oldWord!.Audio, Has.Count.EqualTo(1)); + Assert.That(oldWord!.History, Has.Count.EqualTo(0)); + + // Frontier only has the new word with deleted audio + var frontier = _wordRepo.GetAllFrontier(ProjId).Result; + Assert.That(frontier, Has.Count.EqualTo(1)); + var newWord = frontier.Find(w => w.Id == newId); + Assert.That(newWord, Is.Not.Null); + Assert.That(newWord!.EditedBy.Last(), Is.EqualTo(UserId)); + Assert.That(newWord!.History.Last(), Is.EqualTo(oldId)); } [Test] public void TestUpdateNotInFrontierNull() { - Assert.That(_wordService.Update(ProjId, UserId, WordId, new Word()).Result, Is.Null); + Assert.That(_wordService.Update(UserId, new Word() { Id = WordId, ProjectId = ProjId }).Result, Is.Null); } [Test] @@ -92,8 +108,8 @@ public void TestUpdateReplacesFrontierWord() Assert.That(word, Is.Not.Null); var oldId = word.Id; word.Vernacular = "NewVern"; - Assert.That(_wordService.Update(ProjId, UserId, oldId, word).Result, Is.EqualTo(word.Id)); - var frontier = _wordRepo.GetFrontier(ProjId).Result; + Assert.That(_wordService.Update(UserId, word).Result, Is.EqualTo(word.Id)); + var frontier = _wordRepo.GetAllFrontier(ProjId).Result; Assert.That(frontier, Has.Count.EqualTo(1)); var newWord = frontier.First(); Assert.That(newWord.Id, Is.Not.EqualTo(oldId)); @@ -110,12 +126,12 @@ public void TestUpdateUsingCitationForm() // Update something other than Vernacular and make sure UsingCitationForm is still true. word.Note = new() { Text = "change word's note" }; - _ = _wordService.Update(ProjId, UserId, word.Id, word).Result; + _ = _wordService.Update(UserId, word).Result; Assert.That(word.UsingCitationForm, Is.True); // Update the Vernacular and make sure UsingCitationForm is false. word.Vernacular = "change word's vernacular form"; - _ = _wordService.Update(ProjId, UserId, word.Id, word).Result; + _ = _wordService.Update(UserId, word).Result; Assert.That(word.UsingCitationForm, Is.False); } @@ -131,7 +147,7 @@ public void TestRestoreFrontierWordsFrontierWordFalse() { var wordNoFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; var wordYesFrontier = _wordRepo.Create(new Word { ProjectId = ProjId }).Result; - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(1)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1)); Assert.That( _wordService.RestoreFrontierWords(ProjId, [wordNoFrontier.Id, wordYesFrontier.Id]).Result, Is.False); } @@ -141,9 +157,9 @@ public void TestRestoreFrontierWordsTrue() { var word1 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; var word2 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Is.Empty); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty); Assert.That(_wordService.RestoreFrontierWords(ProjId, [word1.Id, word2.Id]).Result, Is.True); - Assert.That(_wordRepo.GetFrontier(ProjId).Result, Has.Count.EqualTo(2)); + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2)); } [Test] diff --git a/Backend/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index d25175efd4..a07958d5c1 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -123,7 +123,7 @@ public async Task UploadAudioFile( return BadRequest("Empty File"); } - var word = await _wordRepo.GetWord(projectId, wordId); + var word = await _wordRepo.GetFrontier(projectId, wordId); if (word is null) { return NotFound($"wordId: {wordId}"); @@ -144,9 +144,9 @@ public async Task UploadAudioFile( word.Audio.Add(audio); // Update the word with new audio file - await _wordService.Update(projectId, userId, wordId, word); + var newId = await _wordService.Update(userId, word); - return Ok(word.Id); + return newId is null ? NotFound($"wordId: {wordId}") : Ok(newId); } /// Deletes audio in with specified ID @@ -177,12 +177,8 @@ public async Task DeleteAudioFile(string projectId, string wordId return new UnsupportedMediaTypeResult(); } - var newWord = await _wordService.DeleteAudio(projectId, userId, wordId, fileName); - if (newWord is not null) - { - return Ok(newWord.Id); - } - return NotFound($"wordId: {wordId}; fileName: {fileName}"); + var newId = await _wordService.DeleteAudio(projectId, userId, wordId, fileName); + return newId is null ? NotFound($"wordId: {wordId}; fileName: {fileName}") : Ok(newId); } } } diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index dcdc741c8b..e97e6be729 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -110,7 +110,7 @@ public async Task GetProjectFrontierWords(string projectId) { return Forbid(); } - return Ok(await _wordRepo.GetFrontier(projectId)); + return Ok(await _wordRepo.GetAllFrontier(projectId)); } /// Checks if Frontier has in specified . @@ -191,7 +191,7 @@ public async Task UpdateDuplicate( } word.ProjectId = projectId; - var duplicatedWord = await _wordRepo.GetWord(word.ProjectId, dupId); + var duplicatedWord = await _wordRepo.GetFrontier(word.ProjectId, dupId); if (duplicatedWord is null) { return NotFound(); @@ -203,9 +203,8 @@ public async Task UpdateDuplicate( return Conflict(); } - await _wordService.Update(duplicatedWord.ProjectId, userId, duplicatedWord.Id, duplicatedWord); - - return Ok(duplicatedWord.Id); + var newId = await _wordService.Update(userId, duplicatedWord); + return newId is null ? NotFound() : Ok(newId); } /// Creates a . @@ -241,17 +240,13 @@ public async Task UpdateWord( { return Forbid(); } - var document = await _wordRepo.GetWord(projectId, wordId); - if (document is null) - { - return NotFound(); - } - // Add the found id to the updated word. - word.Id = document.Id; - var userId = _permissionService.GetUserId(HttpContext); - await _wordService.Update(projectId, userId, wordId, word); - return Ok(word.Id); + // Don't allow changing project or manually setting the Id. + word.ProjectId = projectId; + word.Id = wordId; + + var newId = await _wordService.Update(_permissionService.GetUserId(HttpContext), word); + return newId is null ? NotFound() : Ok(newId); } /// Restore a deleted . @@ -296,11 +291,15 @@ public async Task RevertWords( foreach (var kv in wordIds) { var idToRevert = kv.Value; - var word = await _wordRepo.GetWord(projectId, kv.Key); - if (word is not null && await _wordRepo.IsInFrontier(projectId, idToRevert)) + var priorWord = await _wordRepo.GetWord(projectId, kv.Key); + if (priorWord is not null) { - await _wordService.Update(projectId, userId, idToRevert, word); - updates[idToRevert] = word.Id; + priorWord.Id = idToRevert; + var newId = await _wordService.Update(userId, priorWord); + if (newId is not null) + { + updates[idToRevert] = newId; + } } } return Ok(updates); diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index 1f0be2c4d3..5cb78e4a4d 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -18,7 +18,8 @@ public interface IWordRepository Task IsInFrontier(string projectId, string wordId); Task AreInFrontier(string projectId, List wordIds, int count); Task GetFrontierCount(string projectId); - Task> GetFrontier(string projectId); + Task> GetAllFrontier(string projectId); + Task GetFrontier(string projectId, string wordId, string? audioFileName = null); Task> GetFrontierWithVernacular(string projectId, string vernacular); Task AddFrontier(Word word); Task> AddFrontier(List words); diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index 9945f2536f..125cd27eef 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -8,8 +8,8 @@ public interface IWordService { Task Create(string userId, Word word); Task> Create(string userId, List words); - Task Update(string projectId, string userId, string wordId, Word word); - Task DeleteAudio(string projectId, string userId, string wordId, string fileName); + Task Update(string userId, Word word); + Task DeleteAudio(string projectId, string userId, string wordId, string fileName); Task DeleteFrontierWord(string projectId, string userId, string wordId); Task RestoreFrontierWords(string projectId, List wordIds); Task FindContainingWord(Word word); diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index ddaa873582..128185d904 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -229,13 +229,25 @@ public async Task GetFrontierCount(string projectId) } /// Finds all s in the Frontier for specified - public async Task> GetFrontier(string projectId) + public async Task> GetAllFrontier(string projectId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting all Frontier words"); return await _frontier.Find(GetAllProjectWordsFilter(projectId)).ToListAsync(); } + /// Gets a specified from the Frontier + /// The word, or null if not found. + public async Task GetFrontier(string projectId, string wordId, string? audioFileName = null) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting a word from Frontier"); + + return string.IsNullOrEmpty(audioFileName) + ? await _frontier.Find(GetProjectWordFilter(projectId, wordId)).FirstOrDefaultAsync() + : await _frontier.Find(GetProjectWordWithAudioFilter(projectId, wordId, audioFileName)) + .FirstOrDefaultAsync(); + } + /// Finds all s in Frontier of specified project with specified vern public async Task> GetFrontierWithVernacular(string projectId, string vernacular) { diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index b00dbea0af..74ef22ebd6 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -282,7 +282,7 @@ public async Task LiftExport(string projectId, IProjectRepository projRe // Get every word with all of its information. var allWords = await wordRepo.GetAllWords(projectId); - var frontier = await wordRepo.GetFrontier(projectId); + var frontier = await wordRepo.GetAllFrontier(projectId); var activeWords = frontier.Where( x => x.Senses.Any(s => s.Accessibility == Status.Active || s.Accessibility == Status.Protected)).ToList(); var hasFlags = activeWords.Any(w => w.Flag.Active); diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index b4c0149e2a..b86d5d6ba9 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -307,7 +307,7 @@ public async Task UpdateMergeBlacklist(string projectId) { return 0; } - var frontierWordIds = (await _wordRepo.GetFrontier(projectId)).Select(word => word.Id); + var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id); var updateCount = 0; foreach (var entry in oldBlacklist) { @@ -346,7 +346,7 @@ public async Task UpdateMergeGraylist(string projectId) { return 0; } - var frontierWordIds = (await _wordRepo.GetFrontier(projectId)).Select(word => word.Id); + var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id); var updateCount = 0; foreach (var entry in oldGraylist) { @@ -402,7 +402,7 @@ public async Task>> GetGraylistEntries( { return []; } - var frontier = await _wordRepo.GetFrontier(projectId); + var frontier = await _wordRepo.GetAllFrontier(projectId); var wordLists = new List> { Capacity = maxLists }; foreach (var entry in graylist) { @@ -443,7 +443,7 @@ public async Task>> GetPotentialDuplicates(string projectId, int { var dupFinder = new DuplicateFinder(maxInList, maxLists, 2); - var collection = await _wordRepo.GetFrontier(projectId); + var collection = await _wordRepo.GetAllFrontier(projectId); async Task isUnavailableSet(List wordIds) => (await IsInMergeBlacklist(projectId, wordIds, userId)) || (await IsInMergeGraylist(projectId, wordIds, userId)); diff --git a/Backend/Services/StatisticsService.cs b/Backend/Services/StatisticsService.cs index 2ad4d7a829..f51a897ecc 100644 --- a/Backend/Services/StatisticsService.cs +++ b/Backend/Services/StatisticsService.cs @@ -41,7 +41,7 @@ public async Task> GetSemanticDomainCounts(string proj var hashMap = new Dictionary(); var domainTreeNodeList = await _domainRepo.GetAllSemanticDomainTreeNodes(lang); - var wordList = await _wordRepo.GetFrontier(projectId); + var wordList = await _wordRepo.GetAllFrontier(projectId); if (domainTreeNodeList is null || domainTreeNodeList.Count == 0 || wordList.Count == 0) { @@ -74,7 +74,7 @@ public async Task> GetWordsPerDayPerUserCounts(str { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting words per day per user counts"); - var wordList = await _wordRepo.GetFrontier(projectId); + var wordList = await _wordRepo.GetAllFrontier(projectId); var shortTimeDictionary = new Dictionary(); var userNameIdDictionary = new Dictionary(); @@ -140,7 +140,7 @@ public async Task GetProgressEstimationLineChartRoot(string proje using var activity = OtelService.StartActivityWithTag(otelTagName, "getting progress estimation line chart root"); var LineChartData = new ChartRootData(); - var wordList = await _wordRepo.GetFrontier(projectId); + var wordList = await _wordRepo.GetAllFrontier(projectId); var workshopSchedule = new List(); var totalCountDictionary = new Dictionary(); @@ -308,7 +308,7 @@ public async Task> GetSemanticDomainUserCounts(str { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting semantic domain user counts"); - var wordList = await _wordRepo.GetFrontier(projectId); + var wordList = await _wordRepo.GetAllFrontier(projectId); var resUserMap = new Dictionary(); // Get all users of the project diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 2d677b1515..6f04880975 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -56,21 +56,19 @@ private async Task Add(string userId, Word word) /// Removes audio with specified fileName from a word /// New word - public async Task DeleteAudio(string projectId, string userId, string wordId, string fileName) + public async Task DeleteAudio(string projectId, string userId, string wordId, string fileName) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting an audio"); // We only want to update words that are in the frontier - var wordWithAudioToDelete = await _wordRepo.DeleteFrontier(projectId, wordId, fileName); + var wordWithAudioToDelete = (await _wordRepo.GetFrontier(projectId, wordId, fileName))?.Clone(); if (wordWithAudioToDelete is null) { return null; } wordWithAudioToDelete.Audio.RemoveAll(a => a.FileName == fileName); - wordWithAudioToDelete.History.Add(wordId); - - return await Create(userId, wordWithAudioToDelete); + return await Update(userId, wordWithAudioToDelete); } /// Deletes word in frontier collection and adds word with deleted tag in word collection @@ -114,25 +112,29 @@ public async Task RestoreFrontierWords(string projectId, List word /// Makes a new word in the Frontier with changes made /// Id of updated word, or null if not found - public async Task Update(string projectId, string userId, string wordId, Word word) + public async Task Update(string userId, Word word) { using var activity = OtelService.StartActivityWithTag(otelTagName, "updating a word in Frontier"); - // We only want to update words that are in the frontier - var oldWord = await _wordRepo.DeleteFrontier(projectId, wordId); + var oldWordId = word.Id; + var oldWord = await _wordRepo.GetFrontier(word.ProjectId, oldWordId); if (oldWord is null) { return null; } + word.Created = oldWord.Created; + word.History.Add(oldWordId); // If an imported word was using the citation form for its Vernacular, // only keep UsingCitationForm true if the Vernacular hasn't changed. word.UsingCitationForm &= word.Vernacular == oldWord.Vernacular; - word.ProjectId = projectId; - word.History.Add(wordId); + var newWordId = (await Create(userId, word)).Id; + + // Don't delete the old word until the new word is successfully created. + await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId); - return (await Create(userId, word)).Id; + return newWordId; } /// Checks if a word being added is a duplicate of a preexisting word. From 0df512ee5ab8aa8804c2dde13af2448e16d73423 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 12 Feb 2026 09:09:14 -0500 Subject: [PATCH 02/14] DeleteFrontierWord: remove at end instead of start --- Backend/Services/WordService.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 6f04880975..a2ba20e066 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -71,13 +71,13 @@ private async Task Add(string userId, Word word) return await Update(userId, wordWithAudioToDelete); } - /// Deletes word in frontier collection and adds word with deleted tag in word collection - /// A string: id of new word + /// Removes word from frontier collection and adds a Deleted copy in the word collection + /// A string: id of Deleted word public async Task DeleteFrontierWord(string projectId, string userId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); - var word = await _wordRepo.DeleteFrontier(projectId, wordId); + var word = (await _wordRepo.GetFrontier(projectId, wordId))?.Clone(); if (word is null) { return null; @@ -87,7 +87,12 @@ private async Task Add(string userId, Word word) word.Accessibility = Status.Deleted; word.History.Add(wordId); - return (await Add(userId, word)).Id; + var deletedId = (await Add(userId, word)).Id; + + // Don't remove the Frontier word until the copy is successfully stored as deleted. + await _wordRepo.DeleteFrontier(projectId, wordId); + + return deletedId; } /// Restores words to the Frontier @@ -131,7 +136,7 @@ public async Task RestoreFrontierWords(string projectId, List word var newWordId = (await Create(userId, word)).Id; - // Don't delete the old word until the new word is successfully created. + // Don't remove the old Frontier word until the new word is successfully created. await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId); return newWordId; From 478402f7aeee947231440cc636f8adbd550aad82 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 12 Feb 2026 10:33:13 -0500 Subject: [PATCH 03/14] Refactor merge handling of children deletion --- .../Controllers/WordControllerTests.cs | 11 +--- Backend.Tests/Services/WordServiceTests.cs | 2 +- Backend/Controllers/WordController.cs | 2 +- Backend/Interfaces/IWordService.cs | 2 +- Backend/Services/MergeService.cs | 59 ++++++++++++++----- Backend/Services/WordService.cs | 19 +++--- 6 files changed, 61 insertions(+), 34 deletions(-) diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index d9fda8eab8..58a3eebd8e 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -273,8 +273,7 @@ public async Task TestUpdateDuplicate() dupWord.Flag = new Flag("New Flag"); var expectedWord = dupWord.Clone(); var result = (ObjectResult)await _wordController.UpdateDuplicate(ProjId, origWord.Id, dupWord); - var id = (string)result.Value!; - var updatedWord = await _wordRepo.GetWord(ProjId, id); + var updatedWord = (Word)result.Value!; Util.AssertEqualWordContent(updatedWord!, expectedWord, true); } @@ -339,12 +338,8 @@ public async Task TestUpdateWord() var modWord = origWord.Clone(); modWord.Vernacular = "NewVernacular"; - var id = (string)((ObjectResult)await _wordController.UpdateWord( - ProjId, modWord.Id, modWord)).Value!; - - var finalWord = modWord.Clone(); - finalWord.Id = id; - finalWord.History = new List { origWord.Id }; + var result = (ObjectResult)await _wordController.UpdateWord(ProjId, modWord.Id, modWord); + var finalWord = (Word)result.Value!; var allWords = await _wordRepo.GetAllWords(ProjId); Assert.That(allWords, Does.Contain(origWord).UsingPropertiesComparer()); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 1dd05a812a..67efcc24ce 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -108,7 +108,7 @@ public void TestUpdateReplacesFrontierWord() Assert.That(word, Is.Not.Null); var oldId = word.Id; word.Vernacular = "NewVern"; - Assert.That(_wordService.Update(UserId, word).Result, Is.EqualTo(word.Id)); + Assert.That(_wordService.Update(UserId, word).Result!.Guid, Is.EqualTo(word.Guid)); var frontier = _wordRepo.GetAllFrontier(ProjId).Result; Assert.That(frontier, Has.Count.EqualTo(1)); var newWord = frontier.First(); diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index e97e6be729..dc3eafc806 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -295,7 +295,7 @@ public async Task RevertWords( if (priorWord is not null) { priorWord.Id = idToRevert; - var newId = await _wordService.Update(userId, priorWord); + var newId = (await _wordService.Update(userId, priorWord))?.Id; if (newId is not null) { updates[idToRevert] = newId; diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index 125cd27eef..a043983910 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -8,7 +8,7 @@ public interface IWordService { Task Create(string userId, Word word); Task> Create(string userId, List words); - Task Update(string userId, Word word); + Task Update(string userId, Word word); Task DeleteAudio(string projectId, string userId, string wordId, string fileName); Task DeleteFrontierWord(string projectId, string userId, string wordId); Task RestoreFrontierWords(string projectId, List wordIds); diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index b86d5d6ba9..17280d3917 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -77,11 +77,16 @@ private ulong StoreDups(string userId, ulong counter, List>? dups) /// Prepares a merge parent to be added to the database. /// Word to add. + /// + /// If the parent has the same guid as a child: + /// * ensures their ids also match; + /// * changes UsingCitationForm to false if their Vernaculars differ. + /// private async Task MergePrepParent(string projectId, MergeWords mergeWords) { var parent = mergeWords.Parent.Clone(); parent.ProjectId = projectId; - parent.History = new List(); + parent.History = []; foreach (var childSource in mergeWords.Children) { @@ -91,6 +96,7 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords if (child.Guid == parent.Guid) { // Update parent's UsingCitationForm. + parent.Id = child.Id; parent.UsingCitationForm = child.UsingCitationForm && parent.Vernacular == child.Vernacular; } @@ -115,27 +121,50 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords return parent; } - /// Deletes all the merge children from the frontier. - /// Number of words deleted. - private async Task MergeDeleteChildren(string projectId, MergeWords mergeWords) - { - var childIds = mergeWords.Children.Select(c => c.SrcWordId).ToList(); - return await _wordRepo.DeleteFrontierWords(projectId, childIds); - } - /// - /// Given a list of MergeWords, preps the words to be added, removes the children - /// from the frontier, and adds the new words to the database. + /// Given a list of MergeWords: preps all the parents from their children; + /// then creates the parents (updating from a child if they have the same guid); + /// then removes from the frontier the children that weren't updated. /// /// List of new words added. public async Task> Merge(string projectId, string userId, List mergeWordsList) { using var activity = OtelService.StartActivityWithTag(otelTagName, "merging words"); - var keptWords = mergeWordsList.Where(m => !m.DeleteOnly); - var newWords = keptWords.Select(m => MergePrepParent(projectId, m).Result).ToList(); - await Task.WhenAll(mergeWordsList.Select(m => MergeDeleteChildren(projectId, m))); - return await _wordService.Create(userId, newWords); + // Prep parents + var parentsToPrep = mergeWordsList.Where(m => !m.DeleteOnly); + var parents = await Task.WhenAll(parentsToPrep.Select(m => MergePrepParent(projectId, m))); + + // Consolidate children ids + var childrenIds = mergeWordsList.SelectMany(m => m.Children.Select(c => c.SrcWordId)).ToHashSet(); + + // Create the parents + List addedParents = []; + foreach (var parent in parents) + { + if (childrenIds.Contains(parent.Id)) + { + var updatedParent = await _wordService.Update(userId, parent); + if (updatedParent is null) + { + addedParents.Add(await _wordService.Create(userId, parent)); + } + else + { + addedParents.Add(updatedParent); + childrenIds.Remove(parent.Id); + } + } + else + { + addedParents.Add(await _wordService.Create(userId, parent)); + } + } + + // Remove the children + await Task.WhenAll(childrenIds.Select(id => _wordService.DeleteFrontierWord(projectId, userId, id))); + + return addedParents; } /// Undo merge diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index a2ba20e066..2369902249 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -68,7 +68,7 @@ private async Task Add(string userId, Word word) } wordWithAudioToDelete.Audio.RemoveAll(a => a.FileName == fileName); - return await Update(userId, wordWithAudioToDelete); + return (await Update(userId, wordWithAudioToDelete))?.Id; } /// Removes word from frontier collection and adds a Deleted copy in the word collection @@ -87,12 +87,12 @@ private async Task Add(string userId, Word word) word.Accessibility = Status.Deleted; word.History.Add(wordId); - var deletedId = (await Add(userId, word)).Id; + var deletedWord = await Add(userId, word); // Don't remove the Frontier word until the copy is successfully stored as deleted. await _wordRepo.DeleteFrontier(projectId, wordId); - return deletedId; + return deletedWord.Id; } /// Restores words to the Frontier @@ -116,8 +116,8 @@ public async Task RestoreFrontierWords(string projectId, List word } /// Makes a new word in the Frontier with changes made - /// Id of updated word, or null if not found - public async Task Update(string userId, Word word) + /// Updated word, or null if word-to-update not found + public async Task Update(string userId, Word word) { using var activity = OtelService.StartActivityWithTag(otelTagName, "updating a word in Frontier"); @@ -129,17 +129,20 @@ public async Task RestoreFrontierWords(string projectId, List word } word.Created = oldWord.Created; - word.History.Add(oldWordId); + if (!word.History.Contains(oldWordId)) + { + word.History.Add(oldWordId); + } // If an imported word was using the citation form for its Vernacular, // only keep UsingCitationForm true if the Vernacular hasn't changed. word.UsingCitationForm &= word.Vernacular == oldWord.Vernacular; - var newWordId = (await Create(userId, word)).Id; + var newWord = await Create(userId, word); // Don't remove the old Frontier word until the new word is successfully created. await _wordRepo.DeleteFrontier(word.ProjectId, oldWordId); - return newWordId; + return newWord; } /// Checks if a word being added is a duplicate of a preexisting word. From 82fe0828b9ba9aef726adfaf2bdd1d6b772af5b2 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 12 Feb 2026 16:25:47 -0500 Subject: [PATCH 04/14] Refactor delete, merge, and undo --- .../Controllers/WordControllerTests.cs | 18 ++++- Backend.Tests/Mocks/WordRepositoryMock.cs | 7 ++ Backend.Tests/Services/WordServiceTests.cs | 12 +-- Backend/Controllers/WordController.cs | 11 +-- Backend/Interfaces/IWordRepository.cs | 1 + Backend/Interfaces/IWordService.cs | 4 +- Backend/Repositories/WordRepository.cs | 12 +++ Backend/Services/MergeService.cs | 35 ++++----- Backend/Services/WordService.cs | 73 ++++++++++++++++--- 9 files changed, 125 insertions(+), 48 deletions(-) diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 58a3eebd8e..03d7a84c68 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -380,8 +380,7 @@ public async Task TestRestoreWord() var result = await _wordController.RestoreWord(ProjId, word.Id); - Assert.That(result, Is.InstanceOf()); - Assert.That(((OkObjectResult)result).Value, Is.True); + Assert.That(result, Is.InstanceOf()); Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); } @@ -400,7 +399,20 @@ public async Task TestRestoreWordNoPermission() public async Task TestRestoreWordMissingWord() { var wordResult = await _wordController.RestoreWord(ProjId, MissingId); - Assert.That(wordResult, Is.InstanceOf()); + Assert.That(wordResult, Is.InstanceOf()); + } + + [Test] + public async Task TestRestoreWordAlreadyInFrontier() + { + var word = await _wordRepo.Create(Util.RandomWord(ProjId)); + + Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); + Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); + + var result = await _wordController.RestoreWord(ProjId, word.Id); + + Assert.That(result, Is.InstanceOf()); } [Test] diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 36f8edad4e..413dad26e4 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -94,6 +94,13 @@ public Task AreInFrontier(string projectId, List wordIds, int coun return Task.FromResult(_frontier.Count(w => w.ProjectId == projectId && wordIds.Contains(w.Id)) >= count); } + public Task AreNonFrontierWords(string projectId, List wordIds) + { + return Task.FromResult(wordIds.All(id => + !_frontier.Any(w => w.ProjectId == projectId && w.Id == id) && + _words.Any(w => w.ProjectId == projectId && w.Id == id))); + } + public Task GetFrontierCount(string projectId) { return Task.FromResult(_frontier.Count(w => w.ProjectId == projectId)); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 67efcc24ce..ede119116f 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -136,29 +136,29 @@ public void TestUpdateUsingCitationForm() } [Test] - public void TestRestoreFrontierWordsMissingWordFalse() + public void TestRestoreToFrontierMissingWordFalse() { var word = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; - Assert.That(_wordService.RestoreFrontierWords(ProjId, ["NotAnId", word.Id]).Result, Is.False); + Assert.That(_wordService.RestoreToFrontier(ProjId, ["NotAnId", word.Id]).Result, Is.False); } [Test] - public void TestRestoreFrontierWordsFrontierWordFalse() + public void TestRestoreToFrontierFrontierWordFalse() { var wordNoFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; var wordYesFrontier = _wordRepo.Create(new Word { ProjectId = ProjId }).Result; Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1)); Assert.That( - _wordService.RestoreFrontierWords(ProjId, [wordNoFrontier.Id, wordYesFrontier.Id]).Result, Is.False); + _wordService.RestoreToFrontier(ProjId, [wordNoFrontier.Id, wordYesFrontier.Id]).Result, Is.False); } [Test] - public void TestRestoreFrontierWordsTrue() + public void TestRestoreToFrontierTrue() { var word1 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; var word2 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty); - Assert.That(_wordService.RestoreFrontierWords(ProjId, [word1.Id, word2.Id]).Result, Is.True); + Assert.That(_wordService.RestoreToFrontier(ProjId, [word1.Id, word2.Id]).Result, Is.True); Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2)); } diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index dc3eafc806..c4dbd73c3e 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -250,11 +250,10 @@ public async Task UpdateWord( } /// Restore a deleted . - /// bool: true if restored; false if already in frontier. [HttpGet("restore/{wordId}", Name = "RestoreWord")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task RestoreWord(string projectId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring a word"); @@ -263,12 +262,8 @@ public async Task RestoreWord(string projectId, string wordId) { return Forbid(); } - if (await _wordRepo.GetWord(projectId, wordId) is null) - { - return NotFound(); - } - return Ok(await _wordService.RestoreFrontierWords(projectId, [wordId])); + return await _wordService.RestoreToFrontier(projectId, wordId) ? Ok() : BadRequest(); } /// Revert words from an dictionary of word ids (key: to revert to; value: from frontier). diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index 5cb78e4a4d..e59dd40755 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -17,6 +17,7 @@ public interface IWordRepository Task HasFrontierWords(string projectId); Task IsInFrontier(string projectId, string wordId); Task AreInFrontier(string projectId, List wordIds, int count); + Task AreNonFrontierWords(string projectId, List wordIds); Task GetFrontierCount(string projectId); Task> GetAllFrontier(string projectId); Task GetFrontier(string projectId, string wordId, string? audioFileName = null); diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index a043983910..2ec23fd7c4 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -11,7 +11,9 @@ public interface IWordService Task Update(string userId, Word word); Task DeleteAudio(string projectId, string userId, string wordId, string fileName); Task DeleteFrontierWord(string projectId, string userId, string wordId); - Task RestoreFrontierWords(string projectId, List wordIds); + Task TryDeleteFrontierWords(string projectId, string userId, List wordIds); + Task RestoreToFrontier(string projectId, string wordId); + Task RestoreToFrontier(string projectId, List wordIds); Task FindContainingWord(Word word); } } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 128185d904..31ff718371 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -220,6 +220,18 @@ public async Task AreInFrontier(string projectId, List wordIds, in .CountDocumentsAsync(GetProjectWordsFilter(projectId, wordIds), new() { Limit = count }) == count; } + /// Checks if given words are in the project words collection but not in the Frontier. + public async Task AreNonFrontierWords(string projectId, List wordIds) + { + using var activity = + OtelService.StartActivityWithTag(otelTagName, "checking if words exist but not in Frontier"); + + // Make sure all the words exist + wordIds = wordIds.Distinct().ToList(); + return !await AreInFrontier(projectId, wordIds, 1) && + wordIds.Count == await _words.CountDocumentsAsync(GetProjectWordsFilter(projectId, wordIds)); + } + /// Gets number of s in the Frontier for specified public async Task GetFrontierCount(string projectId) { diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 17280d3917..fdbb2f8670 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -162,46 +162,43 @@ public async Task> Merge(string projectId, string userId, List _wordService.DeleteFrontierWord(projectId, userId, id))); + await _wordService.TryDeleteFrontierWords(projectId, userId, childrenIds.ToList()); return addedParents; } /// Undo merge - /// True if merge was successfully undone + /// True if merge children were successfully restored public async Task UndoMerge(string projectId, string userId, MergeUndoIds ids) { using var activity = OtelService.StartActivityWithTag(otelTagName, "undoing merge"); - foreach (var parentId in ids.ParentIds) - { - var parentWord = (await _wordRepo.GetWord(projectId, parentId))?.Clone(); - if (parentWord is null) - { - return false; - } - } + var parentIds = ids.ParentIds.Distinct().ToList(); - // If children are not restorable, return without any undo. - if (!await _wordService.RestoreFrontierWords(projectId, ids.ChildIds)) + // If any of the parents aren't in the Frontier, they've been changed since the merge. + if (!await _wordRepo.AreInFrontier(projectId, parentIds, parentIds.Count)) { return false; } - foreach (var parentId in ids.ParentIds) + + // If children are not restorable, return without deleting the merge parents. + if (!await _wordService.RestoreToFrontier(projectId, ids.ChildIds)) { - await _wordService.DeleteFrontierWord(projectId, userId, parentId); + return false; } + + await _wordService.TryDeleteFrontierWords(projectId, userId, parentIds); return true; } /// Adds a List of wordIds to MergeBlacklist of specified . /// Throws when wordIds has count less than 2. /// The created. - public async Task AddToMergeBlacklist( - string projectId, string userId, List wordIds) + public async Task AddToMergeBlacklist(string projectId, string userId, List wordIds) { using var activity = OtelService.StartActivityWithTag(otelTagName, "adding to merge blacklist"); + wordIds = wordIds.Distinct().ToList(); if (wordIds.Count < 2) { throw new InvalidMergeWordSetException("Cannot blacklist a list of fewer than 2 wordIds."); @@ -225,8 +222,7 @@ public async Task AddToMergeBlacklist( /// Adds a List of wordIds to MergeGraylist of specified . /// Throws when wordIds has count less than 2. /// The created. - public async Task AddToMergeGraylist( - string projectId, string userId, List wordIds) + public async Task AddToMergeGraylist(string projectId, string userId, List wordIds) { using var activity = OtelService.StartActivityWithTag(otelTagName, "adding to merge graylist"); @@ -253,8 +249,7 @@ public async Task AddToMergeGraylist( /// Remove a List of wordIds from MergeGraylist of specified . /// Throws when wordIds has count less than 2. /// Boolean indicating whether anything was removed. - public async Task RemoveFromMergeGraylist( - string projectId, string userId, List wordIds) + public async Task RemoveFromMergeGraylist(string projectId, string userId, List wordIds) { using var activity = OtelService.StartActivityWithTag(otelTagName, "removing from merge graylist"); diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 2369902249..107fd62102 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -55,7 +56,7 @@ private async Task Add(string userId, Word word) } /// Removes audio with specified fileName from a word - /// New word + /// A string: id of updated word, or null if not found public async Task DeleteAudio(string projectId, string userId, string wordId, string fileName) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting an audio"); @@ -71,7 +72,7 @@ private async Task Add(string userId, Word word) return (await Update(userId, wordWithAudioToDelete))?.Id; } - /// Removes word from frontier collection and adds a Deleted copy in the word collection + /// Removes word from Frontier and adds a Deleted copy in the word collection /// A string: id of Deleted word public async Task DeleteFrontierWord(string projectId, string userId, string wordId) { @@ -95,23 +96,75 @@ private async Task Add(string userId, Word word) return deletedWord.Id; } - /// Restores words to the Frontier - /// A bool: true if successful, false if any don't exist or are already in the Frontier. - public async Task RestoreFrontierWords(string projectId, List wordIds) + /// Tries to remove words from Frontier and add Deleted copies in the word collection + /// An int: number of successful deletions + public async Task TryDeleteFrontierWords(string projectId, string userId, List wordIds) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting words from Frontier"); + + var deletedCount = 0; + foreach (var wordId in wordIds) + { + try + { + if (await DeleteFrontierWord(projectId, userId, wordId) is not null) + { + deletedCount++; + } + } + catch + { + Console.WriteLine($"Failed to delete word with id {wordId} from Frontier"); + } + } + return deletedCount; + } + + /// Restore a word to the Frontier + /// A bool: true if restored; false if already in frontier or word not found + public async Task RestoreToFrontier(string projectId, string wordId) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring a word to Frontier"); + + if (await _wordRepo.IsInFrontier(projectId, wordId)) + { + return false; + } + + var word = (await _wordRepo.GetWord(projectId, wordId))?.Clone(); + if (word is null) + { + return false; + } + + await _wordRepo.AddFrontier(word); + return true; + } + + /// Restores words to the Frontier that aren't in the Frontier + /// A bool: true if all successfully restored. + public async Task RestoreToFrontier(string projectId, List wordIds) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring words to Frontier"); - var words = new List(); + wordIds = wordIds.Distinct().ToList(); + + // Make sure all the words exist but not in the Frontier + if (!await _wordRepo.AreNonFrontierWords(projectId, wordIds)) + { + return false; + } + + // Restore each word foreach (var id in wordIds) { - var word = await _wordRepo.GetWord(projectId, id); - if (word is null || await _wordRepo.IsInFrontier(projectId, id)) + if (!await RestoreToFrontier(projectId, id)) { + Console.WriteLine($"Failed to restore word with id {id} to Frontier of project {projectId}"); return false; } - words.Add(word); } - await _wordRepo.AddFrontier(words); + return true; } From 7a4739b80cb2c936a2607366d272d413102c3330 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 12 Feb 2026 16:35:28 -0500 Subject: [PATCH 05/14] Mark words as deleted less aggresively in Lift export --- Backend/Services/LiftService.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 74ef22ebd6..e57eeb4a3f 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -283,6 +283,8 @@ public async Task LiftExport(string projectId, IProjectRepository projRe // Get every word with all of its information. var allWords = await wordRepo.GetAllWords(projectId); var frontier = await wordRepo.GetAllFrontier(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. var activeWords = frontier.Where( x => x.Senses.Any(s => s.Accessibility == Status.Active || s.Accessibility == Status.Protected)).ToList(); var hasFlags = activeWords.Any(w => w.Flag.Active); @@ -308,13 +310,12 @@ public async Task LiftExport(string projectId, IProjectRepository projRe // Get all project speakers for exporting audio and consents. var projSpeakers = await speakerRepo.GetAllSpeakers(projectId); - // All words in the frontier with any senses are considered current. - // The Combine does not import senseless entries and the interface is supposed to prevent creating them. - // So the words found in allWords with no matching guid in activeWords are exported as 'deleted'. - var deletedWords = allWords.Where( - x => activeWords.All(w => w.Guid != x.Guid)).DistinctBy(w => w.Guid).ToList(); + // Deleted words found in allWords with no matching guid in activeWords are exported as 'deleted'. + var deletedWords = allWords.Where(w => w.Accessibility == Status.Deleted).DistinctBy(w => w.Guid) + .Where(x => activeWords.All(w => w.Guid != x.Guid)).ToList(); var englishSemDoms = await semDomRepo.GetAllSemanticDomainTreeNodes("en") ?? []; var semDomNames = englishSemDoms.ToDictionary(x => x.Id, x => x.Name); + foreach (var wordEntry in activeWords) { var id = MakeSafeXmlAttribute(wordEntry.Vernacular) + "_" + wordEntry.Guid; From 1a8ad6cf48164ce99b1236dffe603ae579bf8266 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 12 Feb 2026 16:40:05 -0500 Subject: [PATCH 06/14] Update OpenAPI bindings --- src/api/api/word-api.ts | 4 ++-- src/backend/index.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/api/api/word-api.ts b/src/api/api/word-api.ts index 53ac796c1b..ab07e3dcfd 100644 --- a/src/api/api/word-api.ts +++ b/src/api/api/word-api.ts @@ -1041,7 +1041,7 @@ export const WordApiFp = function (configuration?: Configuration) { wordId: string, options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.restoreWord( projectId, @@ -1315,7 +1315,7 @@ export const WordApiFactory = function ( projectId: string, wordId: string, options?: any - ): AxiosPromise { + ): AxiosPromise { return localVarFp .restoreWord(projectId, wordId, options) .then((request) => request(axios, basePath)); diff --git a/src/backend/index.ts b/src/backend/index.ts index 254b7d48fb..e5fc0ecda9 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -959,10 +959,9 @@ export async function isInFrontier( export async function restoreWord( wordId: string, projectId?: string -): Promise { +): Promise { projectId ||= LocalStorage.getProjectId(); - const params = { projectId, wordId }; - return (await wordApi.restoreWord(params, defaultOptions())).data; + await wordApi.restoreWord({ projectId, wordId }, defaultOptions()); } /** Revert word updates given in dictionary of word ids: From 5fddf676350ae03c8300edf3a941fe291f7a4878 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 12 Feb 2026 17:32:40 -0500 Subject: [PATCH 07/14] Revert unnecessary RestoreFrontierWords changes --- Backend.Tests/Mocks/WordRepositoryMock.cs | 16 +++++----- Backend.Tests/Services/WordServiceTests.cs | 12 ++++---- Backend/Controllers/WordController.cs | 2 +- Backend/Interfaces/IWordRepository.cs | 2 +- Backend/Interfaces/IWordService.cs | 3 +- Backend/Repositories/WordRepository.cs | 20 +++++------- Backend/Services/MergeService.cs | 2 +- Backend/Services/WordService.cs | 36 ++++------------------ 8 files changed, 31 insertions(+), 62 deletions(-) diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 413dad26e4..6de7cbc8d6 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -35,8 +35,7 @@ public Task> GetAllWords(string projectId) { try { - var foundWord = _words.Single(word => word.Id == wordId); - return Task.FromResult(foundWord.Clone()); + return Task.FromResult(_words.Single(w => w.ProjectId == projectId && w.Id == wordId).Clone()); } catch (InvalidOperationException) { @@ -44,6 +43,12 @@ public Task> GetAllWords(string projectId) } } + public async Task> GetWords(string projectId, List wordIds) + { + return await Task.FromResult( + _words.Where(w => w.ProjectId == projectId && wordIds.Contains(w.Id)).Select(w => w.Clone()).ToList()); + } + public Task Create(Word word) { word.Id = Guid.NewGuid().ToString(); @@ -94,13 +99,6 @@ public Task AreInFrontier(string projectId, List wordIds, int coun return Task.FromResult(_frontier.Count(w => w.ProjectId == projectId && wordIds.Contains(w.Id)) >= count); } - public Task AreNonFrontierWords(string projectId, List wordIds) - { - return Task.FromResult(wordIds.All(id => - !_frontier.Any(w => w.ProjectId == projectId && w.Id == id) && - _words.Any(w => w.ProjectId == projectId && w.Id == id))); - } - public Task GetFrontierCount(string projectId) { return Task.FromResult(_frontier.Count(w => w.ProjectId == projectId)); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index ede119116f..67efcc24ce 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -136,29 +136,29 @@ public void TestUpdateUsingCitationForm() } [Test] - public void TestRestoreToFrontierMissingWordFalse() + public void TestRestoreFrontierWordsMissingWordFalse() { var word = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; - Assert.That(_wordService.RestoreToFrontier(ProjId, ["NotAnId", word.Id]).Result, Is.False); + Assert.That(_wordService.RestoreFrontierWords(ProjId, ["NotAnId", word.Id]).Result, Is.False); } [Test] - public void TestRestoreToFrontierFrontierWordFalse() + public void TestRestoreFrontierWordsFrontierWordFalse() { var wordNoFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; var wordYesFrontier = _wordRepo.Create(new Word { ProjectId = ProjId }).Result; Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1)); Assert.That( - _wordService.RestoreToFrontier(ProjId, [wordNoFrontier.Id, wordYesFrontier.Id]).Result, Is.False); + _wordService.RestoreFrontierWords(ProjId, [wordNoFrontier.Id, wordYesFrontier.Id]).Result, Is.False); } [Test] - public void TestRestoreToFrontierTrue() + public void TestRestoreFrontierWordsTrue() { var word1 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; var word2 = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty); - Assert.That(_wordService.RestoreToFrontier(ProjId, [word1.Id, word2.Id]).Result, Is.True); + Assert.That(_wordService.RestoreFrontierWords(ProjId, [word1.Id, word2.Id]).Result, Is.True); Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(2)); } diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index c4dbd73c3e..03c13b0dc9 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -263,7 +263,7 @@ public async Task RestoreWord(string projectId, string wordId) return Forbid(); } - return await _wordService.RestoreToFrontier(projectId, wordId) ? Ok() : BadRequest(); + return await _wordService.RestoreFrontierWords(projectId, [wordId]) ? Ok() : BadRequest(); } /// Revert words from an dictionary of word ids (key: to revert to; value: from frontier). diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index e59dd40755..36a6cc6938 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -8,6 +8,7 @@ public interface IWordRepository { Task> GetAllWords(string projectId); Task GetWord(string projectId, string wordId); + Task> GetWords(string projectId, List wordIds); Task Create(Word word); Task> Create(List words); Task Add(Word word); @@ -17,7 +18,6 @@ public interface IWordRepository Task HasFrontierWords(string projectId); Task IsInFrontier(string projectId, string wordId); Task AreInFrontier(string projectId, List wordIds, int count); - Task AreNonFrontierWords(string projectId, List wordIds); Task GetFrontierCount(string projectId); Task> GetAllFrontier(string projectId); Task GetFrontier(string projectId, string wordId, string? audioFileName = null); diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index 2ec23fd7c4..5ad3a5998b 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -12,8 +12,7 @@ public interface IWordService Task DeleteAudio(string projectId, string userId, string wordId, string fileName); Task DeleteFrontierWord(string projectId, string userId, string wordId); Task TryDeleteFrontierWords(string projectId, string userId, List wordIds); - Task RestoreToFrontier(string projectId, string wordId); - Task RestoreToFrontier(string projectId, List wordIds); + Task RestoreFrontierWords(string projectId, List wordIds); Task FindContainingWord(Word word); } } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 31ff718371..5f5cef091b 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -80,6 +80,14 @@ public async Task> GetAllWords(string projectId) } } + /// Finds project s with specified ids + public async Task> GetWords(string projectId, List wordIds) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting words"); + + return await _words.Find(GetProjectWordsFilter(projectId, wordIds)).ToListAsync(); + } + /// Removes all s from the WordsCollection and Frontier for specified /// /// A bool: success of operation @@ -220,18 +228,6 @@ public async Task AreInFrontier(string projectId, List wordIds, in .CountDocumentsAsync(GetProjectWordsFilter(projectId, wordIds), new() { Limit = count }) == count; } - /// Checks if given words are in the project words collection but not in the Frontier. - public async Task AreNonFrontierWords(string projectId, List wordIds) - { - using var activity = - OtelService.StartActivityWithTag(otelTagName, "checking if words exist but not in Frontier"); - - // Make sure all the words exist - wordIds = wordIds.Distinct().ToList(); - return !await AreInFrontier(projectId, wordIds, 1) && - wordIds.Count == await _words.CountDocumentsAsync(GetProjectWordsFilter(projectId, wordIds)); - } - /// Gets number of s in the Frontier for specified public async Task GetFrontierCount(string projectId) { diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index fdbb2f8670..4fdedaeb85 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -182,7 +182,7 @@ public async Task UndoMerge(string projectId, string userId, MergeUndoIds } // If children are not restorable, return without deleting the merge parents. - if (!await _wordService.RestoreToFrontier(projectId, ids.ChildIds)) + if (!await _wordService.RestoreFrontierWords(projectId, ids.ChildIds)) { return false; } diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 107fd62102..a6d5beb8cc 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -120,51 +120,27 @@ public async Task TryDeleteFrontierWords(string projectId, string userId, L return deletedCount; } - /// Restore a word to the Frontier - /// A bool: true if restored; false if already in frontier or word not found - public async Task RestoreToFrontier(string projectId, string wordId) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring a word to Frontier"); - - if (await _wordRepo.IsInFrontier(projectId, wordId)) - { - return false; - } - - var word = (await _wordRepo.GetWord(projectId, wordId))?.Clone(); - if (word is null) - { - return false; - } - - await _wordRepo.AddFrontier(word); - return true; - } - /// Restores words to the Frontier that aren't in the Frontier /// A bool: true if all successfully restored. - public async Task RestoreToFrontier(string projectId, List wordIds) + public async Task RestoreFrontierWords(string projectId, List wordIds) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring words to Frontier"); wordIds = wordIds.Distinct().ToList(); // Make sure all the words exist but not in the Frontier - if (!await _wordRepo.AreNonFrontierWords(projectId, wordIds)) + if (await _wordRepo.AreInFrontier(projectId, wordIds, 1)) { return false; } - // Restore each word - foreach (var id in wordIds) + var wordsToRestore = await _wordRepo.GetWords(projectId, wordIds); + if (wordsToRestore.Count != wordIds.Count) { - if (!await RestoreToFrontier(projectId, id)) - { - Console.WriteLine($"Failed to restore word with id {id} to Frontier of project {projectId}"); - return false; - } + return false; } + await _wordRepo.AddFrontier(wordsToRestore); return true; } From 5ea31312c8de32866339c92735ed1f53d9ee79c0 Mon Sep 17 00:00:00 2001 From: "D. Ror" Date: Fri, 13 Feb 2026 06:34:50 -0500 Subject: [PATCH 08/14] Distinguish Merged from Deleted; Respond to bunny review --- .../Controllers/AudioControllerTests.cs | 5 ++-- Backend.Tests/Mocks/WordRepositoryMock.cs | 6 ++--- Backend.Tests/Services/WordServiceTests.cs | 3 ++- Backend/Controllers/AudioController.cs | 4 ++-- Backend/Interfaces/IWordService.cs | 6 +++-- Backend/Models/Sense.cs | 3 ++- Backend/Services/LiftService.cs | 9 +++++--- Backend/Services/MergeService.cs | 6 ++--- Backend/Services/WordService.cs | 23 ++++++++++++++++--- src/api/models/status.ts | 1 + 10 files changed, 46 insertions(+), 20 deletions(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index 19061a134b..b0b7328064 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -178,10 +178,11 @@ public void TestDeleteAudioFile() // Test delete function var result = _audioController.DeleteAudioFile(_projId, oldId, fileName).Result as OkObjectResult; + var newId = result?.Value as string; // Ensure returned id in different - var newId = result?.Value as string; - Assert.That(newId, Is.Not.Null.Or.EqualTo(oldId)); + Assert.That(newId, Is.Not.Null); + Assert.That(newId, Is.Not.EqualTo(oldId)); // Ensure the word with deleted audio is in the frontier var frontier = _wordRepo.GetAllFrontier(_projId).Result; diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index 6de7cbc8d6..d121766f97 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -43,9 +43,9 @@ public Task> GetAllWords(string projectId) } } - public async Task> GetWords(string projectId, List wordIds) + public Task> GetWords(string projectId, List wordIds) { - return await Task.FromResult( + return Task.FromResult( _words.Where(w => w.ProjectId == projectId && wordIds.Contains(w.Id)).Select(w => w.Clone()).ToList()); } @@ -123,7 +123,7 @@ public async Task> GetAllFrontier(string projectId) { var word = _frontier.Find(w => w.ProjectId == projectId && w.Id == wordId && (string.IsNullOrEmpty(audioFileName) || w.Audio.Any(a => a.FileName == audioFileName))); - return Task.FromResult(word); + return Task.FromResult(word?.Clone()); } public Task> GetFrontierWithVernacular(string projectId, string vernacular) diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 67efcc24ce..ba1657b366 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -75,7 +75,8 @@ public void TestDeleteAudio() var oldId = wordInFrontier.Id; var newId = _wordService.DeleteAudio(ProjId, UserId, oldId, fileName).Result; - Assert.That(newId, Is.Not.Null.Or.EqualTo(oldId)); + Assert.That(newId, Is.Not.Null); + Assert.That(newId, Is.Not.EqualTo(oldId)); // Original word persists var allWords = _wordRepo.GetAllWords(ProjId).Result; diff --git a/Backend/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index a07958d5c1..9b2881e6f0 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -144,9 +144,9 @@ public async Task UploadAudioFile( word.Audio.Add(audio); // Update the word with new audio file - var newId = await _wordService.Update(userId, word); + var updatedWord = await _wordService.Update(userId, word); - return newId is null ? NotFound($"wordId: {wordId}") : Ok(newId); + return updatedWord is null ? NotFound($"wordId: {wordId}") : Ok(updatedWord.Id); } /// Deletes audio in with specified ID diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index 5ad3a5998b..fa3e8528af 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -10,8 +10,10 @@ public interface IWordService Task> Create(string userId, List words); Task Update(string userId, Word word); Task DeleteAudio(string projectId, string userId, string wordId, string fileName); - Task DeleteFrontierWord(string projectId, string userId, string wordId); - Task TryDeleteFrontierWords(string projectId, string userId, List wordIds); + Task DeleteFrontierWord( + string projectId, string userId, string wordId, Status? status = Status.Deleted); + Task TryDeleteFrontierWords( + string projectId, string userId, List wordIds, Status? status = Status.Deleted); Task RestoreFrontierWords(string projectId, List wordIds); Task FindContainingWord(Word word); } diff --git a/Backend/Models/Sense.cs b/Backend/Models/Sense.cs index a08950e402..2484a55890 100644 --- a/Backend/Models/Sense.cs +++ b/Backend/Models/Sense.cs @@ -243,6 +243,7 @@ public enum Status Active, Deleted, Duplicate, - Protected + Merged, + Protected, } } diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index e57eeb4a3f..6d7995868f 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -310,9 +310,12 @@ public async Task LiftExport(string projectId, IProjectRepository projRe // Get all project speakers for exporting audio and consents. var projSpeakers = await speakerRepo.GetAllSpeakers(projectId); - // Deleted words found in allWords with no matching guid in activeWords are exported as 'deleted'. - var deletedWords = allWords.Where(w => w.Accessibility == Status.Deleted).DistinctBy(w => w.Guid) - .Where(x => activeWords.All(w => w.Guid != x.Guid)).ToList(); + // Deleted or merged words found in allWords with no matching guid in activeWords are exported as 'deleted'. + var activeWordGuids = activeWords.Select(w => w.Guid).ToHashSet(); + var deletedWords = allWords + .Where(w => w.Accessibility == Status.Deleted || w.Accessibility == Status.Merged) + .DistinctBy(w => w.Guid) + .Where(x => !activeWordGuids.Contains(x.Guid)).ToList(); var englishSemDoms = await semDomRepo.GetAllSemanticDomainTreeNodes("en") ?? []; var semDomNames = englishSemDoms.ToDictionary(x => x.Id, x => x.Name); diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 4fdedaeb85..46a2f673ec 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -162,7 +162,7 @@ public async Task> Merge(string projectId, string userId, List UpdateMergeBlacklist(string projectId) { return 0; } - var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id); + var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id).ToHashSet(); var updateCount = 0; foreach (var entry in oldBlacklist) { @@ -370,7 +370,7 @@ public async Task UpdateMergeGraylist(string projectId) { return 0; } - var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id); + var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id).ToHashSet(); var updateCount = 0; foreach (var entry in oldGraylist) { diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index a6d5beb8cc..d4c05d7d31 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -73,8 +73,13 @@ private async Task Add(string userId, Word word) } /// Removes word from Frontier and adds a Deleted copy in the word collection + /// The project the word is in + /// The user performing the deletion + /// The id of the word to delete + /// Optional status to set the deleted word to instead of Deleted /// A string: id of Deleted word - public async Task DeleteFrontierWord(string projectId, string userId, string wordId) + public async Task DeleteFrontierWord( + string projectId, string userId, string wordId, Status? status = Status.Deleted) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); @@ -97,8 +102,13 @@ private async Task Add(string userId, Word word) } /// Tries to remove words from Frontier and add Deleted copies in the word collection + /// The project the word is in + /// The user performing the deletion + /// The ids of the words to delete + /// Optional status to set the deleted word to instead of Deleted /// An int: number of successful deletions - public async Task TryDeleteFrontierWords(string projectId, string userId, List wordIds) + public async Task TryDeleteFrontierWords( + string projectId, string userId, List wordIds, Status? status = Status.Deleted) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting words from Frontier"); @@ -107,7 +117,7 @@ public async Task TryDeleteFrontierWords(string projectId, string userId, L { try { - if (await DeleteFrontierWord(projectId, userId, wordId) is not null) + if (await DeleteFrontierWord(projectId, userId, wordId, status) is not null) { deletedCount++; } @@ -135,10 +145,17 @@ public async Task RestoreFrontierWords(string projectId, List word } var wordsToRestore = await _wordRepo.GetWords(projectId, wordIds); + // Make sure all the words are valid if (wordsToRestore.Count != wordIds.Count) { return false; } + if (wordsToRestore.Any(w => w.Accessibility == Status.Deleted || w.Accessibility == Status.Merged)) + { + // We should be restoring words that was removed from the Frontier, + // and not their "Deleted" or "Merged" copies in the word collection. + return false; + } await _wordRepo.AddFrontier(wordsToRestore); return true; diff --git a/src/api/models/status.ts b/src/api/models/status.ts index 88fd8874f1..b0724b0b5a 100644 --- a/src/api/models/status.ts +++ b/src/api/models/status.ts @@ -21,5 +21,6 @@ export enum Status { Active = "Active", Deleted = "Deleted", Duplicate = "Duplicate", + Merged = "Merged", Protected = "Protected", } From 921ebf5ff1c200d4ede21c420dfc3fc48c48348b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 13 Feb 2026 08:45:15 -0500 Subject: [PATCH 09/14] Fix and simplify deletion status handling --- .../Controllers/LiftControllerTests.cs | 2 +- Backend.Tests/Services/WordServiceTests.cs | 54 +++++++++++++++++-- Backend/Controllers/WordController.cs | 2 +- Backend/Interfaces/IWordService.cs | 5 +- Backend/Services/MergeService.cs | 8 ++- Backend/Services/WordService.cs | 43 ++------------- 6 files changed, 62 insertions(+), 52 deletions(-) diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 0770c11de7..8935412dc0 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -417,7 +417,7 @@ public async Task TestDeletedWordsExportToLift() word.Vernacular = "updated"; await _wordService.Update(UserId, word); - await _wordService.DeleteFrontierWord(_projId, UserId, wordToDelete.Id); + await _wordService.DeleteFrontierWord(_projId, UserId, wordToDelete.Id, Status.Deleted); _liftService.SetExportInProgress(UserId, ExportId); await _liftController.CreateLiftExportThenSignal(_projId, UserId, ExportId); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index ba1657b366..462d15291e 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -96,6 +96,50 @@ public void TestDeleteAudio() Assert.That(newWord!.History.Last(), Is.EqualTo(oldId)); } + [Test] + public void TestDeleteFrontierWordNotInFrontierNull() + { + var wordNotInFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; + Assert.That(_wordService.DeleteFrontierWord(ProjId, UserId, wordNotInFrontier.Id, Status.Deleted).Result, + Is.Null); + Assert.That(_wordService.DeleteFrontierWord("wrong-proj", UserId, WordId, Status.Deleted).Result, Is.Null); + } + + [Test] + public void TestDeleteFrontierWordCopiesToWordsAndRemovesFrontier() + { + var oldId = _wordRepo.Create(new Word { ProjectId = ProjId }).Result.Id; + + var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, oldId, Status.Deleted).Result; + + Assert.That(deletedId, Is.Not.Null); + Assert.That(deletedId, Is.Not.EqualTo(oldId)); + var deletedWord = _wordRepo.GetWord(ProjId, deletedId!).Result; + Assert.That(deletedWord, Is.Not.Null); + Assert.That(deletedWord!.Accessibility, Is.EqualTo(Status.Deleted)); + Assert.That(deletedWord!.History.Last(), Is.EqualTo(oldId)); + Assert.That(deletedWord!.EditedBy.Last(), Is.EqualTo(UserId)); + + var allWordIds = _wordRepo.GetAllWords(ProjId).Result.Select(w => w.Id).ToList(); + Assert.That(allWordIds, Has.Count.EqualTo(2)); + Assert.That(allWordIds, Does.Contain(oldId)); + Assert.That(allWordIds, Does.Contain(deletedId!)); + + Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty); + } + + [Test] + public void TestDeleteFrontierWordMergedStatus() + { + var oldId = _wordRepo.Create(new Word { ProjectId = ProjId }).Result.Id; + + var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, oldId, Status.Merged).Result; + + var deletedWord = _wordRepo.GetWord(ProjId, deletedId!).Result; + Assert.That(deletedWord, Is.Not.Null); + Assert.That(deletedWord!.Accessibility, Is.EqualTo(Status.Merged)); + } + [Test] public void TestUpdateNotInFrontierNull() { @@ -127,13 +171,13 @@ public void TestUpdateUsingCitationForm() // Update something other than Vernacular and make sure UsingCitationForm is still true. word.Note = new() { Text = "change word's note" }; - _ = _wordService.Update(UserId, word).Result; - Assert.That(word.UsingCitationForm, Is.True); + var nonVernUpdate = _wordService.Update(UserId, word).Result; + Assert.That(nonVernUpdate!.UsingCitationForm, Is.True); // Update the Vernacular and make sure UsingCitationForm is false. - word.Vernacular = "change word's vernacular form"; - _ = _wordService.Update(UserId, word).Result; - Assert.That(word.UsingCitationForm, Is.False); + nonVernUpdate.Vernacular = "change word's vernacular form"; + var vernUpdate = _wordService.Update(UserId, nonVernUpdate).Result; + Assert.That(vernUpdate!.UsingCitationForm, Is.False); } [Test] diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index 03c13b0dc9..46c49686fa 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -43,7 +43,7 @@ public async Task DeleteFrontierWord(string projectId, string wor } var userId = _permissionService.GetUserId(HttpContext); - var deletedWordId = await _wordService.DeleteFrontierWord(projectId, userId, wordId); + var deletedWordId = await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted); return deletedWordId is null ? NotFound() : Ok(); } diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index fa3e8528af..ef45319043 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -10,10 +10,7 @@ public interface IWordService Task> Create(string userId, List words); Task Update(string userId, Word word); Task DeleteAudio(string projectId, string userId, string wordId, string fileName); - Task DeleteFrontierWord( - string projectId, string userId, string wordId, Status? status = Status.Deleted); - Task TryDeleteFrontierWords( - string projectId, string userId, List wordIds, Status? status = Status.Deleted); + Task DeleteFrontierWord(string projectId, string userId, string wordId, Status status); Task RestoreFrontierWords(string projectId, List wordIds); Task FindContainingWord(Word word); } diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 46a2f673ec..84d6348692 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -162,7 +162,8 @@ public async Task> Merge(string projectId, string userId, List await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Merged))); return addedParents; } @@ -187,7 +188,10 @@ public async Task UndoMerge(string projectId, string userId, MergeUndoIds return false; } - await _wordService.TryDeleteFrontierWords(projectId, userId, parentIds); + // Remove the parents + await Task.WhenAll(parentIds.Select( + async wordId => await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted))); + return true; } diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index d4c05d7d31..0d3754cb86 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -72,14 +71,9 @@ private async Task Add(string userId, Word word) return (await Update(userId, wordWithAudioToDelete))?.Id; } - /// Removes word from Frontier and adds a Deleted copy in the word collection - /// The project the word is in - /// The user performing the deletion - /// The id of the word to delete - /// Optional status to set the deleted word to instead of Deleted + /// Removes word from Frontier and adds a copy with given status in the word collection /// A string: id of Deleted word - public async Task DeleteFrontierWord( - string projectId, string userId, string wordId, Status? status = Status.Deleted) + public async Task DeleteFrontierWord(string projectId, string userId, string wordId, Status status) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); @@ -90,7 +84,7 @@ private async Task Add(string userId, Word word) } word.ProjectId = projectId; - word.Accessibility = Status.Deleted; + word.Accessibility = status; word.History.Add(wordId); var deletedWord = await Add(userId, word); @@ -101,35 +95,6 @@ private async Task Add(string userId, Word word) return deletedWord.Id; } - /// Tries to remove words from Frontier and add Deleted copies in the word collection - /// The project the word is in - /// The user performing the deletion - /// The ids of the words to delete - /// Optional status to set the deleted word to instead of Deleted - /// An int: number of successful deletions - public async Task TryDeleteFrontierWords( - string projectId, string userId, List wordIds, Status? status = Status.Deleted) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting words from Frontier"); - - var deletedCount = 0; - foreach (var wordId in wordIds) - { - try - { - if (await DeleteFrontierWord(projectId, userId, wordId, status) is not null) - { - deletedCount++; - } - } - catch - { - Console.WriteLine($"Failed to delete word with id {wordId} from Frontier"); - } - } - return deletedCount; - } - /// Restores words to the Frontier that aren't in the Frontier /// A bool: true if all successfully restored. public async Task RestoreFrontierWords(string projectId, List wordIds) @@ -167,7 +132,7 @@ public async Task RestoreFrontierWords(string projectId, List word { using var activity = OtelService.StartActivityWithTag(otelTagName, "updating a word in Frontier"); - var oldWordId = word.Id; + var oldWordId = word.Id; // Capture the old Id before it's cleared via Create. var oldWord = await _wordRepo.GetFrontier(word.ProjectId, oldWordId); if (oldWord is null) { From 839b224b955b9d535eae12531fba4c842f13dd9a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 13 Feb 2026 09:32:22 -0500 Subject: [PATCH 10/14] Fix ActionResult return type mismatch --- .../Controllers/WordControllerTests.cs | 11 +++-- Backend/Controllers/AudioController.cs | 22 ++++----- Backend/Controllers/WordController.cs | 46 +++++++++---------- 3 files changed, 38 insertions(+), 41 deletions(-) diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 03d7a84c68..1e95dfd925 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -273,7 +273,8 @@ public async Task TestUpdateDuplicate() dupWord.Flag = new Flag("New Flag"); var expectedWord = dupWord.Clone(); var result = (ObjectResult)await _wordController.UpdateDuplicate(ProjId, origWord.Id, dupWord); - var updatedWord = (Word)result.Value!; + var id = (string)result.Value!; + var updatedWord = await _wordRepo.GetWord(ProjId, id); Util.AssertEqualWordContent(updatedWord!, expectedWord, true); } @@ -338,8 +339,12 @@ public async Task TestUpdateWord() var modWord = origWord.Clone(); modWord.Vernacular = "NewVernacular"; - var result = (ObjectResult)await _wordController.UpdateWord(ProjId, modWord.Id, modWord); - var finalWord = (Word)result.Value!; + var id = (string)((ObjectResult)await _wordController.UpdateWord( + ProjId, modWord.Id, modWord)).Value!; + + var finalWord = modWord.Clone(); + finalWord.Id = id; + finalWord.History = new List { origWord.Id }; var allWords = await _wordRepo.GetAllWords(ProjId); Assert.That(allWords, Does.Contain(origWord).UsingPropertiesComparer()); diff --git a/Backend/Controllers/AudioController.cs b/Backend/Controllers/AudioController.cs index 9b2881e6f0..e03c2c6a37 100644 --- a/Backend/Controllers/AudioController.cs +++ b/Backend/Controllers/AudioController.cs @@ -13,21 +13,15 @@ namespace BackendFramework.Controllers [Authorize] [Produces("application/json")] [Route("v1/projects/{projectId}/words/{wordId}/audio")] - public class AudioController : Controller + public class AudioController( + IWordRepository wordRepo, IWordService wordService, IPermissionService permissionService) : Controller { - private readonly IWordRepository _wordRepo; - private readonly IPermissionService _permissionService; - private readonly IWordService _wordService; + private readonly IWordRepository _wordRepo = wordRepo; + private readonly IPermissionService _permissionService = permissionService; + private readonly IWordService _wordService = wordService; private const string otelTagName = "otel.AudioController"; - public AudioController(IWordRepository repo, IWordService wordService, IPermissionService permissionService) - { - _wordRepo = repo; - _permissionService = permissionService; - _wordService = wordService; - } - /// Gets the audio file in the form of a stream from disk. /// Audio file stream. [AllowAnonymous] @@ -144,9 +138,9 @@ public async Task UploadAudioFile( word.Audio.Add(audio); // Update the word with new audio file - var updatedWord = await _wordService.Update(userId, word); + string? updatedId = (await _wordService.Update(userId, word))?.Id; - return updatedWord is null ? NotFound($"wordId: {wordId}") : Ok(updatedWord.Id); + return updatedId is null ? NotFound($"wordId: {wordId}") : Ok(updatedId); } /// Deletes audio in with specified ID @@ -177,7 +171,7 @@ public async Task DeleteAudioFile(string projectId, string wordId return new UnsupportedMediaTypeResult(); } - var newId = await _wordService.DeleteAudio(projectId, userId, wordId, fileName); + string? newId = await _wordService.DeleteAudio(projectId, userId, wordId, fileName); return newId is null ? NotFound($"wordId: {wordId}; fileName: {fileName}") : Ok(newId); } } diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index 46c49686fa..0c8e818903 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -13,21 +13,15 @@ namespace BackendFramework.Controllers [Authorize] [Produces("application/json")] [Route("v1/projects/{projectId}/words")] - public class WordController : Controller + public class WordController( + IWordRepository wordRepo, IWordService wordService, IPermissionService permissionService) : Controller { - private readonly IWordRepository _wordRepo; - private readonly IPermissionService _permissionService; - private readonly IWordService _wordService; + private readonly IWordRepository _wordRepo = wordRepo; + private readonly IPermissionService _permissionService = permissionService; + private readonly IWordService _wordService = wordService; private const string otelTagName = "otel.WordController"; - public WordController(IWordRepository repo, IWordService wordService, IPermissionService permissionService) - { - _wordRepo = repo; - _permissionService = permissionService; - _wordService = wordService; - } - /// Deletes specified Frontier . [HttpDelete("frontier/{wordId}", Name = "DeleteFrontierWord")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -43,8 +37,8 @@ public async Task DeleteFrontierWord(string projectId, string wor } var userId = _permissionService.GetUserId(HttpContext); - var deletedWordId = await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted); - return deletedWordId is null ? NotFound() : Ok(); + var deleted = await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted); + return deleted is null ? NotFound() : Ok(); } /// Returns with specified id. @@ -60,12 +54,9 @@ public async Task GetWord(string projectId, string wordId) { return Forbid(); } - var word = await _wordRepo.GetWord(projectId, wordId); - if (word is null) - { - return NotFound(); - } - return Ok(word); + + Word? word = await _wordRepo.GetWord(projectId, wordId); + return word is null ? NotFound() : Ok(word); } /// Checks if Frontier for specified has any words. @@ -80,6 +71,7 @@ public async Task HasFrontierWords(string projectId) { return Forbid(); } + return Ok(await _wordRepo.HasFrontierWords(projectId)); } @@ -95,6 +87,7 @@ public async Task GetFrontierCount(string projectId) { return Forbid(); } + return Ok(await _wordRepo.GetFrontierCount(projectId)); } @@ -110,6 +103,7 @@ public async Task GetProjectFrontierWords(string projectId) { return Forbid(); } + return Ok(await _wordRepo.GetAllFrontier(projectId)); } @@ -125,6 +119,7 @@ public async Task IsInFrontier(string projectId, string wordId) { return Forbid(); } + return Ok(await _wordRepo.IsInFrontier(projectId, wordId)); } @@ -170,7 +165,8 @@ public async Task GetDuplicateId(string projectId, [FromBody, Bin } word.ProjectId = projectId; - return Ok(await _wordService.FindContainingWord(word) ?? ""); + string? containingId = await _wordService.FindContainingWord(word); + return Ok(containingId ?? ""); } /// Combines a into the existing duplicate with specified wordId. @@ -203,7 +199,7 @@ public async Task UpdateDuplicate( return Conflict(); } - var newId = await _wordService.Update(userId, duplicatedWord); + string? newId = (await _wordService.Update(userId, duplicatedWord))?.Id; return newId is null ? NotFound() : Ok(newId); } @@ -220,9 +216,11 @@ public async Task CreateWord(string projectId, [FromBody, BindReq { return Forbid(); } + word.ProjectId = projectId; - var userId = _permissionService.GetUserId(HttpContext); - return Ok((await _wordService.Create(userId, word)).Id); + + string newId = (await _wordService.Create(_permissionService.GetUserId(HttpContext), word)).Id; + return Ok(newId); } /// Updates a . @@ -245,7 +243,7 @@ public async Task UpdateWord( word.ProjectId = projectId; word.Id = wordId; - var newId = await _wordService.Update(_permissionService.GetUserId(HttpContext), word); + string? newId = (await _wordService.Update(_permissionService.GetUserId(HttpContext), word))?.Id; return newId is null ? NotFound() : Ok(newId); } From a1068fb9f4870e59a12f0e4f99254aacf24780e1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 13 Feb 2026 10:06:57 -0500 Subject: [PATCH 11/14] Simplify merge; Fix typos; Minor bunny fixes --- Backend/Controllers/WordController.cs | 2 +- Backend/Repositories/WordRepository.cs | 2 -- Backend/Services/MergeService.cs | 36 +++++++++++--------------- Backend/Services/WordService.cs | 14 +++++----- 4 files changed, 23 insertions(+), 31 deletions(-) diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index 0c8e818903..788ec8d7ae 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -264,7 +264,7 @@ public async Task RestoreWord(string projectId, string wordId) return await _wordService.RestoreFrontierWords(projectId, [wordId]) ? Ok() : BadRequest(); } - /// Revert words from an dictionary of word ids (key: to revert to; value: from frontier). + /// Revert words from a dictionary of word ids (key: to revert to; value: from frontier). /// Id dictionary of all words successfully updated (key: was in frontier; value: new id). [HttpPost("revertwords", Name = "RevertWords")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary))] diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index 5f5cef091b..b30c62d58e 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -138,7 +138,6 @@ private static void PopulateBlankWordTimes(Word word) /// If the Created or Modified time fields are blank, they will automatically calculated using the current /// time. This allows services to set or clear the values before creation to control these fields. /// - /// /// The word created public async Task Create(Word word) { @@ -156,7 +155,6 @@ public async Task Create(Word word) /// If the Created or Modified time fields are blank, they will automatically calculated using the current /// time. This allows services to set or clear the values before creation to control these fields. /// - /// /// The words created public async Task> Create(List words) { diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 84d6348692..7733735f11 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -139,37 +139,31 @@ public async Task> Merge(string projectId, string userId, List m.Children.Select(c => c.SrcWordId)).ToHashSet(); // Create the parents - List addedParents = []; + var addedParents = new List(); foreach (var parent in parents) { - if (childrenIds.Contains(parent.Id)) + var parentId = parent.Id; // Capture the id in case of changes. + Word? updatedParent = null; + if (childrenIds.Contains(parentId)) { - var updatedParent = await _wordService.Update(userId, parent); - if (updatedParent is null) + updatedParent = await _wordService.Update(userId, parent); + if (updatedParent is not null) { - addedParents.Add(await _wordService.Create(userId, parent)); + childrenIds.Remove(parentId); } - else - { - addedParents.Add(updatedParent); - childrenIds.Remove(parent.Id); - } - } - else - { - addedParents.Add(await _wordService.Create(userId, parent)); } + addedParents.Add(updatedParent ?? await _wordService.Create(userId, parent)); } // Remove the children await Task.WhenAll(childrenIds.Select( - async wordId => await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Merged))); + wordId => _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Merged))); return addedParents; } /// Undo merge - /// True if merge children were successfully restored + /// A bool: true if merge children were successfully restored public async Task UndoMerge(string projectId, string userId, MergeUndoIds ids) { using var activity = OtelService.StartActivityWithTag(otelTagName, "undoing merge"); @@ -190,7 +184,7 @@ public async Task UndoMerge(string projectId, string userId, MergeUndoIds // Remove the parents await Task.WhenAll(parentIds.Select( - async wordId => await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted))); + wordId => _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted))); return true; } @@ -335,11 +329,11 @@ public async Task UpdateMergeBlacklist(string projectId) { return 0; } - var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id).ToHashSet(); + var frontierIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id).ToHashSet(); var updateCount = 0; foreach (var entry in oldBlacklist) { - var newIds = entry.WordIds.Where(id => frontierWordIds.Contains(id)).ToList(); + var newIds = entry.WordIds.Where(id => frontierIds.Contains(id)).ToList(); if (newIds.Count == entry.WordIds.Count) { continue; @@ -374,11 +368,11 @@ public async Task UpdateMergeGraylist(string projectId) { return 0; } - var frontierWordIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id).ToHashSet(); + var frontierIds = (await _wordRepo.GetAllFrontier(projectId)).Select(word => word.Id).ToHashSet(); var updateCount = 0; foreach (var entry in oldGraylist) { - var newIds = entry.WordIds.Where(id => frontierWordIds.Contains(id)).ToList(); + var newIds = entry.WordIds.Where(id => frontierIds.Contains(id)).ToList(); if (newIds.Count == entry.WordIds.Count) { continue; diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 0d3754cb86..072fb37097 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -71,8 +71,8 @@ private async Task Add(string userId, Word word) return (await Update(userId, wordWithAudioToDelete))?.Id; } - /// Removes word from Frontier and adds a copy with given status in the word collection - /// A string: id of Deleted word + /// Removes word from Frontier and adds a copy with given status in the words collection + /// A string: id of deleted word public async Task DeleteFrontierWord(string projectId, string userId, string wordId, Status status) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); @@ -96,14 +96,14 @@ private async Task Add(string userId, Word word) } /// Restores words to the Frontier that aren't in the Frontier - /// A bool: true if all successfully restored. + /// A bool: true if all successfully restored public async Task RestoreFrontierWords(string projectId, List wordIds) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring words to Frontier"); wordIds = wordIds.Distinct().ToList(); - // Make sure all the words exist but not in the Frontier + // Make sure all the words exist but not in the Frontier. if (await _wordRepo.AreInFrontier(projectId, wordIds, 1)) { return false; @@ -117,8 +117,8 @@ public async Task RestoreFrontierWords(string projectId, List word } if (wordsToRestore.Any(w => w.Accessibility == Status.Deleted || w.Accessibility == Status.Merged)) { - // We should be restoring words that was removed from the Frontier, - // and not their "Deleted" or "Merged" copies in the word collection. + // We should be restoring words that were removed from the Frontier, + // and not their "Deleted" or "Merged" copies in the words collection. return false; } @@ -132,7 +132,7 @@ public async Task RestoreFrontierWords(string projectId, List word { using var activity = OtelService.StartActivityWithTag(otelTagName, "updating a word in Frontier"); - var oldWordId = word.Id; // Capture the old Id before it's cleared via Create. + var oldWordId = word.Id; // Capture the id in case of changes. var oldWord = await _wordRepo.GetFrontier(word.ProjectId, oldWordId); if (oldWord is null) { From 1ad4951e4038f0236655a9ca1c13cbb835958b48 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 13 Feb 2026 13:13:05 -0500 Subject: [PATCH 12/14] Don't distinguish Merged from Deleted --- .../Controllers/LiftControllerTests.cs | 2 +- Backend.Tests/Mocks/WordRepositoryMock.cs | 8 -------- Backend.Tests/Services/WordServiceTests.cs | 19 +++---------------- Backend/Controllers/WordController.cs | 2 +- Backend/Interfaces/IWordRepository.cs | 1 - Backend/Interfaces/IWordService.cs | 2 +- Backend/Models/Sense.cs | 1 - Backend/Repositories/WordRepository.cs | 10 ---------- Backend/Services/LiftService.cs | 6 ++---- Backend/Services/MergeService.cs | 8 ++++---- Backend/Services/WordService.cs | 10 +++++----- src/api/models/status.ts | 1 - 12 files changed, 17 insertions(+), 53 deletions(-) diff --git a/Backend.Tests/Controllers/LiftControllerTests.cs b/Backend.Tests/Controllers/LiftControllerTests.cs index 8935412dc0..0770c11de7 100644 --- a/Backend.Tests/Controllers/LiftControllerTests.cs +++ b/Backend.Tests/Controllers/LiftControllerTests.cs @@ -417,7 +417,7 @@ public async Task TestDeletedWordsExportToLift() word.Vernacular = "updated"; await _wordService.Update(UserId, word); - await _wordService.DeleteFrontierWord(_projId, UserId, wordToDelete.Id, Status.Deleted); + await _wordService.DeleteFrontierWord(_projId, UserId, wordToDelete.Id); _liftService.SetExportInProgress(UserId, ExportId); await _liftController.CreateLiftExportThenSignal(_projId, UserId, ExportId); diff --git a/Backend.Tests/Mocks/WordRepositoryMock.cs b/Backend.Tests/Mocks/WordRepositoryMock.cs index d121766f97..6b34034717 100644 --- a/Backend.Tests/Mocks/WordRepositoryMock.cs +++ b/Backend.Tests/Mocks/WordRepositoryMock.cs @@ -156,14 +156,6 @@ public Task> AddFrontier(List words) return Task.FromResult(word); } - public Task DeleteFrontierWords(string projectId, List wordIds) - { - long deletedCount = 0; - wordIds.ForEach(id => deletedCount += _frontier.RemoveAll( - word => word.ProjectId == projectId && word.Id == id)); - return Task.FromResult(deletedCount); - } - public Task Add(Word word) { word.Id = Guid.NewGuid().ToString(); diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 462d15291e..8f2f693e6b 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -100,9 +100,8 @@ public void TestDeleteAudio() public void TestDeleteFrontierWordNotInFrontierNull() { var wordNotInFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; - Assert.That(_wordService.DeleteFrontierWord(ProjId, UserId, wordNotInFrontier.Id, Status.Deleted).Result, - Is.Null); - Assert.That(_wordService.DeleteFrontierWord("wrong-proj", UserId, WordId, Status.Deleted).Result, Is.Null); + Assert.That(_wordService.DeleteFrontierWord(ProjId, UserId, wordNotInFrontier.Id).Result, Is.Null); + Assert.That(_wordService.DeleteFrontierWord("wrong-proj", UserId, WordId).Result, Is.Null); } [Test] @@ -110,7 +109,7 @@ public void TestDeleteFrontierWordCopiesToWordsAndRemovesFrontier() { var oldId = _wordRepo.Create(new Word { ProjectId = ProjId }).Result.Id; - var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, oldId, Status.Deleted).Result; + var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, oldId).Result; Assert.That(deletedId, Is.Not.Null); Assert.That(deletedId, Is.Not.EqualTo(oldId)); @@ -128,18 +127,6 @@ public void TestDeleteFrontierWordCopiesToWordsAndRemovesFrontier() Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty); } - [Test] - public void TestDeleteFrontierWordMergedStatus() - { - var oldId = _wordRepo.Create(new Word { ProjectId = ProjId }).Result.Id; - - var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, oldId, Status.Merged).Result; - - var deletedWord = _wordRepo.GetWord(ProjId, deletedId!).Result; - Assert.That(deletedWord, Is.Not.Null); - Assert.That(deletedWord!.Accessibility, Is.EqualTo(Status.Merged)); - } - [Test] public void TestUpdateNotInFrontierNull() { diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index 788ec8d7ae..490d6ac538 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -37,7 +37,7 @@ public async Task DeleteFrontierWord(string projectId, string wor } var userId = _permissionService.GetUserId(HttpContext); - var deleted = await _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted); + var deleted = await _wordService.DeleteFrontierWord(projectId, userId, wordId); return deleted is null ? NotFound() : Ok(); } diff --git a/Backend/Interfaces/IWordRepository.cs b/Backend/Interfaces/IWordRepository.cs index 36a6cc6938..1aabed7bbd 100644 --- a/Backend/Interfaces/IWordRepository.cs +++ b/Backend/Interfaces/IWordRepository.cs @@ -25,7 +25,6 @@ public interface IWordRepository Task AddFrontier(Word word); Task> AddFrontier(List words); Task DeleteFrontier(string projectId, string wordId, string? audioFileName = null); - Task DeleteFrontierWords(string projectId, List wordIds); Task CountFrontierWordsWithDomain(string projectId, string domainId); } } diff --git a/Backend/Interfaces/IWordService.cs b/Backend/Interfaces/IWordService.cs index ef45319043..a043983910 100644 --- a/Backend/Interfaces/IWordService.cs +++ b/Backend/Interfaces/IWordService.cs @@ -10,7 +10,7 @@ public interface IWordService Task> Create(string userId, List words); Task Update(string userId, Word word); Task DeleteAudio(string projectId, string userId, string wordId, string fileName); - Task DeleteFrontierWord(string projectId, string userId, string wordId, Status status); + Task DeleteFrontierWord(string projectId, string userId, string wordId); Task RestoreFrontierWords(string projectId, List wordIds); Task FindContainingWord(Word word); } diff --git a/Backend/Models/Sense.cs b/Backend/Models/Sense.cs index 2484a55890..a74bbfba74 100644 --- a/Backend/Models/Sense.cs +++ b/Backend/Models/Sense.cs @@ -243,7 +243,6 @@ public enum Status Active, Deleted, Duplicate, - Merged, Protected, } } diff --git a/Backend/Repositories/WordRepository.cs b/Backend/Repositories/WordRepository.cs index b30c62d58e..592127f59f 100644 --- a/Backend/Repositories/WordRepository.cs +++ b/Backend/Repositories/WordRepository.cs @@ -297,16 +297,6 @@ public async Task> AddFrontier(List words) GetProjectWordWithAudioFilter(projectId, wordId, audioFileName)); } - /// Removes s from the Frontier with specified wordIds and projectId - /// Number of words deleted - public async Task DeleteFrontierWords(string projectId, List wordIds) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting words from Frontier"); - - var deleted = await _frontier.DeleteManyAsync(GetProjectWordsFilter(projectId, wordIds)); - return deleted.DeletedCount; - } - /// /// Counts the number of Frontier words that have the specified semantic domain. /// diff --git a/Backend/Services/LiftService.cs b/Backend/Services/LiftService.cs index 6d7995868f..95aa64cc89 100644 --- a/Backend/Services/LiftService.cs +++ b/Backend/Services/LiftService.cs @@ -310,11 +310,9 @@ public async Task LiftExport(string projectId, IProjectRepository projRe // Get all project speakers for exporting audio and consents. var projSpeakers = await speakerRepo.GetAllSpeakers(projectId); - // Deleted or merged words found in allWords with no matching guid in activeWords are exported as 'deleted'. + // Deleted words found in allWords with no matching guid in activeWords are exported as 'deleted'. var activeWordGuids = activeWords.Select(w => w.Guid).ToHashSet(); - var deletedWords = allWords - .Where(w => w.Accessibility == Status.Deleted || w.Accessibility == Status.Merged) - .DistinctBy(w => w.Guid) + var deletedWords = allWords.Where(w => w.Accessibility == Status.Deleted).DistinctBy(w => w.Guid) .Where(x => !activeWordGuids.Contains(x.Guid)).ToList(); var englishSemDoms = await semDomRepo.GetAllSemanticDomainTreeNodes("en") ?? []; var semDomNames = englishSemDoms.ToDictionary(x => x.Id, x => x.Name); diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index 7733735f11..19eb041e91 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -93,6 +93,8 @@ private async Task MergePrepParent(string projectId, MergeWords mergeWords var child = await _wordRepo.GetWord(projectId, childSource.SrcWordId) ?? throw new KeyNotFoundException($"Unable to locate word: ${childSource.SrcWordId}"); + // We can assume only one child has the same guid as the parent. If that is somehow not the case, + // only one will be updated to the parent and the rest deleted, which is okay. if (child.Guid == parent.Guid) { // Update parent's UsingCitationForm. @@ -156,8 +158,7 @@ public async Task> Merge(string projectId, string userId, List _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Merged))); + await Task.WhenAll(childrenIds.Select(id => _wordService.DeleteFrontierWord(projectId, userId, id))); return addedParents; } @@ -183,8 +184,7 @@ public async Task UndoMerge(string projectId, string userId, MergeUndoIds } // Remove the parents - await Task.WhenAll(parentIds.Select( - wordId => _wordService.DeleteFrontierWord(projectId, userId, wordId, Status.Deleted))); + await Task.WhenAll(parentIds.Select(id => _wordService.DeleteFrontierWord(projectId, userId, id))); return true; } diff --git a/Backend/Services/WordService.cs b/Backend/Services/WordService.cs index 072fb37097..a330dca010 100644 --- a/Backend/Services/WordService.cs +++ b/Backend/Services/WordService.cs @@ -71,9 +71,9 @@ private async Task Add(string userId, Word word) return (await Update(userId, wordWithAudioToDelete))?.Id; } - /// Removes word from Frontier and adds a copy with given status in the words collection + /// Removes word from Frontier and adds a Deleted copy in the words collection /// A string: id of deleted word - public async Task DeleteFrontierWord(string projectId, string userId, string wordId, Status status) + public async Task DeleteFrontierWord(string projectId, string userId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "deleting a word from Frontier"); @@ -84,7 +84,7 @@ private async Task Add(string userId, Word word) } word.ProjectId = projectId; - word.Accessibility = status; + word.Accessibility = Status.Deleted; word.History.Add(wordId); var deletedWord = await Add(userId, word); @@ -115,10 +115,10 @@ public async Task RestoreFrontierWords(string projectId, List word { return false; } - if (wordsToRestore.Any(w => w.Accessibility == Status.Deleted || w.Accessibility == Status.Merged)) + if (wordsToRestore.Any(w => w.Accessibility == Status.Deleted)) { // We should be restoring words that were removed from the Frontier, - // and not their "Deleted" or "Merged" copies in the words collection. + // and not their "Deleted" copies in the words collection. return false; } diff --git a/src/api/models/status.ts b/src/api/models/status.ts index b0724b0b5a..88fd8874f1 100644 --- a/src/api/models/status.ts +++ b/src/api/models/status.ts @@ -21,6 +21,5 @@ export enum Status { Active = "Active", Deleted = "Deleted", Duplicate = "Duplicate", - Merged = "Merged", Protected = "Protected", } From 30e5998f9b7306ddaa19aff15f5d0b6cb917a472 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 13 Feb 2026 14:57:52 -0500 Subject: [PATCH 13/14] Revert out-of-scope controller/frontend API change --- .../Controllers/WordControllerTests.cs | 34 +++++++++++-------- Backend/Controllers/WordController.cs | 12 +++++-- Backend/Models/Sense.cs | 2 +- src/api/api/word-api.ts | 4 +-- src/backend/index.ts | 5 +-- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Backend.Tests/Controllers/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 1e95dfd925..74c39d385c 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -385,9 +385,26 @@ public async Task TestRestoreWord() var result = await _wordController.RestoreWord(ProjId, word.Id); - Assert.That(result, Is.InstanceOf()); + Assert.That(result, Is.InstanceOf()); + Assert.That(((OkObjectResult)result).Value, Is.True); + Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); + Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); + } + + [Test] + public async Task TestRestoreWordAlreadyInFrontier() + { + var word = await _wordRepo.Create(Util.RandomWord(ProjId)); + Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); + var frontierCount = await _wordRepo.GetFrontierCount(ProjId); + + var result = await _wordController.RestoreWord(ProjId, word.Id); + + Assert.That(result, Is.InstanceOf()); + Assert.That(((OkObjectResult)result).Value, Is.False); + Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(frontierCount)); } [Test] @@ -404,20 +421,7 @@ public async Task TestRestoreWordNoPermission() public async Task TestRestoreWordMissingWord() { var wordResult = await _wordController.RestoreWord(ProjId, MissingId); - Assert.That(wordResult, Is.InstanceOf()); - } - - [Test] - public async Task TestRestoreWordAlreadyInFrontier() - { - var word = await _wordRepo.Create(Util.RandomWord(ProjId)); - - Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); - Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); - - var result = await _wordController.RestoreWord(ProjId, word.Id); - - Assert.That(result, Is.InstanceOf()); + Assert.That(wordResult, Is.InstanceOf()); } [Test] diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index 490d6ac538..6270d4aa9c 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -248,10 +248,12 @@ public async Task UpdateWord( } /// Restore a deleted . + /// bool: true if restored; false if already in frontier. [HttpGet("restore/{wordId}", Name = "RestoreWord")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RestoreWord(string projectId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring a word"); @@ -260,8 +262,12 @@ public async Task RestoreWord(string projectId, string wordId) { return Forbid(); } + if (await _wordRepo.GetWord(projectId, wordId) is null) + { + return NotFound(); + } - return await _wordService.RestoreFrontierWords(projectId, [wordId]) ? Ok() : BadRequest(); + return Ok(await _wordService.RestoreFrontierWords(projectId, [wordId])); } /// Revert words from a dictionary of word ids (key: to revert to; value: from frontier). diff --git a/Backend/Models/Sense.cs b/Backend/Models/Sense.cs index a74bbfba74..a08950e402 100644 --- a/Backend/Models/Sense.cs +++ b/Backend/Models/Sense.cs @@ -243,6 +243,6 @@ public enum Status Active, Deleted, Duplicate, - Protected, + Protected } } diff --git a/src/api/api/word-api.ts b/src/api/api/word-api.ts index ab07e3dcfd..53ac796c1b 100644 --- a/src/api/api/word-api.ts +++ b/src/api/api/word-api.ts @@ -1041,7 +1041,7 @@ export const WordApiFp = function (configuration?: Configuration) { wordId: string, options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.restoreWord( projectId, @@ -1315,7 +1315,7 @@ export const WordApiFactory = function ( projectId: string, wordId: string, options?: any - ): AxiosPromise { + ): AxiosPromise { return localVarFp .restoreWord(projectId, wordId, options) .then((request) => request(axios, basePath)); diff --git a/src/backend/index.ts b/src/backend/index.ts index e5fc0ecda9..254b7d48fb 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -959,9 +959,10 @@ export async function isInFrontier( export async function restoreWord( wordId: string, projectId?: string -): Promise { +): Promise { projectId ||= LocalStorage.getProjectId(); - await wordApi.restoreWord({ projectId, wordId }, defaultOptions()); + const params = { projectId, wordId }; + return (await wordApi.restoreWord(params, defaultOptions())).data; } /** Revert word updates given in dictionary of word ids: From 7a99eec5ca4e5c0d7289b70c422eb7ae9451ce66 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 20 Feb 2026 08:25:30 -0500 Subject: [PATCH 14/14] Split off test updates into other prs --- .../Controllers/AudioControllerTests.cs | 24 +++++--- .../Controllers/WordControllerTests.cs | 16 ----- Backend.Tests/Services/WordServiceTests.cs | 58 ++----------------- Backend/Controllers/WordController.cs | 1 - 4 files changed, 20 insertions(+), 79 deletions(-) diff --git a/Backend.Tests/Controllers/AudioControllerTests.cs b/Backend.Tests/Controllers/AudioControllerTests.cs index b0b7328064..a831e82ef1 100644 --- a/Backend.Tests/Controllers/AudioControllerTests.cs +++ b/Backend.Tests/Controllers/AudioControllerTests.cs @@ -170,25 +170,31 @@ public void TestDeleteAudioFile() { // Refill test database _wordRepo.DeleteAllWords(_projId); - _wordRepo.DeleteAllFrontierWords(_projId); var origWord = Util.RandomWord(_projId); const string fileName = "a.wav"; origWord.Audio.Add(new Pronunciation(fileName)); - var oldId = _wordRepo.Create(origWord).Result.Id; + var wordId = _wordRepo.Create(origWord).Result.Id; // Test delete function - var result = _audioController.DeleteAudioFile(_projId, oldId, fileName).Result as OkObjectResult; - var newId = result?.Value as string; + _ = _audioController.DeleteAudioFile(_projId, wordId, fileName).Result; - // Ensure returned id in different - Assert.That(newId, Is.Not.Null); - Assert.That(newId, Is.Not.EqualTo(oldId)); + // Original word persists + Assert.That(_wordRepo.GetAllWords(_projId).Result, Has.Count.EqualTo(2)); - // Ensure the word with deleted audio is in the frontier + // Get the new word from the database var frontier = _wordRepo.GetAllFrontier(_projId).Result; + + // Ensure the new word has no audio files + Assert.That(frontier[0].Audio, Has.Count.EqualTo(0)); + + // Test the frontier + Assert.That(_wordRepo.GetAllFrontier(_projId).Result, Has.Count.EqualTo(1)); + + // Ensure the word with deleted audio is in the frontier Assert.That(frontier, Has.Count.EqualTo(1)); - Assert.That(frontier[0].Id, Is.EqualTo(newId)); + 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/WordControllerTests.cs b/Backend.Tests/Controllers/WordControllerTests.cs index 74c39d385c..d9fda8eab8 100644 --- a/Backend.Tests/Controllers/WordControllerTests.cs +++ b/Backend.Tests/Controllers/WordControllerTests.cs @@ -391,22 +391,6 @@ public async Task TestRestoreWord() Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); } - [Test] - public async Task TestRestoreWordAlreadyInFrontier() - { - var word = await _wordRepo.Create(Util.RandomWord(ProjId)); - - Assert.That(await _wordRepo.GetAllWords(ProjId), Does.Contain(word).UsingPropertiesComparer()); - Assert.That(await _wordRepo.GetAllFrontier(ProjId), Does.Contain(word).UsingPropertiesComparer()); - var frontierCount = await _wordRepo.GetFrontierCount(ProjId); - - var result = await _wordController.RestoreWord(ProjId, word.Id); - - Assert.That(result, Is.InstanceOf()); - Assert.That(((OkObjectResult)result).Value, Is.False); - Assert.That(await _wordRepo.GetFrontierCount(ProjId), Is.EqualTo(frontierCount)); - } - [Test] public async Task TestRestoreWordNoPermission() { diff --git a/Backend.Tests/Services/WordServiceTests.cs b/Backend.Tests/Services/WordServiceTests.cs index 7111b1dfe4..c5f19d840c 100644 --- a/Backend.Tests/Services/WordServiceTests.cs +++ b/Backend.Tests/Services/WordServiceTests.cs @@ -72,59 +72,11 @@ public void TestDeleteAudio() var fileName = "audio.mp3"; var wordInFrontier = _wordRepo.Create( new Word() { Audio = [new() { FileName = fileName }], ProjectId = ProjId }).Result; - var oldId = wordInFrontier.Id; - - var newWord = _wordService.DeleteAudio(ProjId, UserId, oldId, fileName).Result; - - // New word is correct - Assert.That(newWord, Is.Not.Null); - Assert.That(newWord!.Id, Is.Not.EqualTo(oldId)); - Assert.That(newWord!.EditedBy.Last(), Is.EqualTo(UserId)); - Assert.That(newWord!.History.Last(), Is.EqualTo(oldId)); - - // New word is only one in frontier - Assert.That(_wordRepo.IsInFrontier(ProjId, newWord.Id).Result, Is.True); - Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Has.Count.EqualTo(1)); - - // Original word persists - var allWords = _wordRepo.GetAllWords(ProjId).Result; - Assert.That(allWords, Has.Count.EqualTo(2)); - Assert.That(allWords.Find(w => w.Id == newWord.Id), Is.Not.Null); - var oldWord = allWords.Find(w => w.Id == oldId); - Assert.That(oldWord, Is.Not.Null); - Assert.That(oldWord!.Audio, Has.Count.EqualTo(1)); - Assert.That(oldWord!.History, Has.Count.EqualTo(0)); - } - - [Test] - public void TestDeleteFrontierWordNotInFrontierNull() - { - var wordNotInFrontier = _wordRepo.Add(new Word { ProjectId = ProjId }).Result; - Assert.That(_wordService.DeleteFrontierWord(ProjId, UserId, wordNotInFrontier.Id).Result, Is.Null); - Assert.That(_wordService.DeleteFrontierWord("wrong-proj", UserId, WordId).Result, Is.Null); - } - - [Test] - public void TestDeleteFrontierWordCopiesToWordsAndRemovesFrontier() - { - var oldId = _wordRepo.Create(new Word { ProjectId = ProjId }).Result.Id; - - var deletedId = _wordService.DeleteFrontierWord(ProjId, UserId, oldId).Result; - - Assert.That(deletedId, Is.Not.Null); - Assert.That(deletedId, Is.Not.EqualTo(oldId)); - var deletedWord = _wordRepo.GetWord(ProjId, deletedId!).Result; - Assert.That(deletedWord, Is.Not.Null); - Assert.That(deletedWord!.Accessibility, Is.EqualTo(Status.Deleted)); - Assert.That(deletedWord!.History.Last(), Is.EqualTo(oldId)); - Assert.That(deletedWord!.EditedBy.Last(), Is.EqualTo(UserId)); - - var allWordIds = _wordRepo.GetAllWords(ProjId).Result.Select(w => w.Id).ToList(); - Assert.That(allWordIds, Has.Count.EqualTo(2)); - Assert.That(allWordIds, Does.Contain(oldId)); - Assert.That(allWordIds, Does.Contain(deletedId!)); - - Assert.That(_wordRepo.GetAllFrontier(ProjId).Result, Is.Empty); + var result = _wordService.DeleteAudio(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] diff --git a/Backend/Controllers/WordController.cs b/Backend/Controllers/WordController.cs index 6270d4aa9c..f0b37b7796 100644 --- a/Backend/Controllers/WordController.cs +++ b/Backend/Controllers/WordController.cs @@ -253,7 +253,6 @@ public async Task UpdateWord( [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(bool))] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task RestoreWord(string projectId, string wordId) { using var activity = OtelService.StartActivityWithTag(otelTagName, "restoring a word");