From a9c0f82e7a43d4e81c97366f670c30cba7ad03f3 Mon Sep 17 00:00:00 2001 From: Zachary Van Gorkom <69218730+Apoktieno@users.noreply.github.com> Date: Fri, 29 Sep 2023 08:49:34 -0700 Subject: [PATCH] Add goal Review Deferred Duplicates to handle skipped merge sets (#2610) Co-authored-by: D. Ror --- .vscode/settings.json | 1 + .../Controllers/MergeControllerTests.cs | 33 +- Backend.Tests/Helper/DuplicateFinderTests.cs | 18 +- .../Mocks/MergeBlacklistRepositoryMock.cs | 28 +- .../Mocks/MergeGraylistRepositoryMock.cs | 75 +++++ ...listEntryTests.cs => MergeWordSetTests.cs} | 24 +- Backend.Tests/Services/MergeServiceTests.cs | 164 +++++++++- Backend/Contexts/MergeBlacklistContext.cs | 2 +- Backend/Contexts/MergeGraylistContext.cs | 23 ++ Backend/Controllers/MergeController.cs | 39 ++- Backend/Helper/DuplicateFinder.cs | 12 +- Backend/Interfaces/IMergeBlacklistContext.cs | 2 +- .../Interfaces/IMergeBlacklistRepository.cs | 10 +- Backend/Interfaces/IMergeGraylistContext.cs | 10 + .../Interfaces/IMergeGraylistRepository.cs | 17 + Backend/Interfaces/IMergeService.cs | 7 +- ...MergeBlacklistEntry.cs => MergeWordSet.cs} | 12 +- Backend/Models/UserEdit.cs | 14 +- .../Repositories/MergeBlacklistRepository.cs | 44 +-- .../Repositories/MergeGraylistRepository.cs | 101 ++++++ Backend/Services/MergeService.cs | 178 +++++++++-- Backend/Startup.cs | 2 + docs/user_guide/default/images/mergeDefer.png | Bin 0 -> 6365 bytes docs/user_guide/default/images/mergeSkip.png | Bin 10480 -> 0 bytes docs/user_guide/docs/en/goals.md | 10 +- public/locales/en/translation.json | 11 +- src/api/api/merge-api.ts | 294 ++++++++++++++++++ src/backend/index.ts | 19 ++ src/components/GoalTimeline/DefaultState.ts | 2 + src/components/GoalTimeline/GoalList.tsx | 4 +- .../GoalTimeline/Redux/GoalActions.ts | 15 +- .../GoalTimeline/Redux/GoalReducer.ts | 10 +- src/components/GoalTimeline/index.tsx | 18 +- .../GoalTimeline/tests/GoalRedux.test.tsx | 29 ++ .../GoalTimeline/tests/index.test.tsx | 16 +- src/goals/DefaultGoal/BaseGoalScreen.tsx | 3 + src/goals/DefaultGoal/DisplayProgress.tsx | 4 +- ...veSkipButtons.tsx => SaveDeferButtons.tsx} | 35 ++- .../MergeDuplicates/MergeDupsStep/index.tsx | 4 +- src/goals/MergeDuplicates/MergeDupsTypes.ts | 14 + .../MergeDuplicates/Redux/MergeDupsActions.ts | 17 +- src/goals/ReviewDeferredDuplicates/index.tsx | 12 + src/goals/SpellCheckGloss/SpellCheckGloss.ts | 2 +- src/types/goals.ts | 6 +- src/utilities/goalUtilities.ts | 12 +- 45 files changed, 1167 insertions(+), 186 deletions(-) create mode 100644 Backend.Tests/Mocks/MergeGraylistRepositoryMock.cs rename Backend.Tests/Models/{MergeBlacklistEntryTests.cs => MergeWordSetTests.cs} (77%) create mode 100644 Backend/Contexts/MergeGraylistContext.cs create mode 100644 Backend/Interfaces/IMergeGraylistContext.cs create mode 100644 Backend/Interfaces/IMergeGraylistRepository.cs rename Backend/Models/{MergeBlacklistEntry.cs => MergeWordSet.cs} (84%) create mode 100644 Backend/Repositories/MergeGraylistRepository.cs create mode 100644 docs/user_guide/default/images/mergeDefer.png delete mode 100644 docs/user_guide/default/images/mergeSkip.png rename src/goals/MergeDuplicates/MergeDupsStep/{SaveSkipButtons.tsx => SaveDeferButtons.tsx} (65%) create mode 100644 src/goals/ReviewDeferredDuplicates/index.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 4aabc3c815..509e6f0f60 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -50,6 +50,7 @@ "Dups", "endcap", "globaltool", + "graylist", "Guids", "kubeconfig", "langtags", diff --git a/Backend.Tests/Controllers/MergeControllerTests.cs b/Backend.Tests/Controllers/MergeControllerTests.cs index 459313feef..85e797f7e2 100644 --- a/Backend.Tests/Controllers/MergeControllerTests.cs +++ b/Backend.Tests/Controllers/MergeControllerTests.cs @@ -12,6 +12,7 @@ namespace Backend.Tests.Controllers public class MergeControllerTests : IDisposable { private IMergeBlacklistRepository _mergeBlacklistRepo = null!; + private IMergeGraylistRepository _mergeGraylistRepo = null!; private IWordRepository _wordRepo = null!; private IMergeService _mergeService = null!; private IPermissionService _permissionService = null!; @@ -38,9 +39,10 @@ protected virtual void Dispose(bool disposing) public void Setup() { _mergeBlacklistRepo = new MergeBlacklistRepositoryMock(); + _mergeGraylistRepo = new MergeGraylistRepositoryMock(); _wordRepo = new WordRepositoryMock(); _wordService = new WordService(_wordRepo); - _mergeService = new MergeService(_mergeBlacklistRepo, _wordRepo, _wordService); + _mergeService = new MergeService(_mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService); _permissionService = new PermissionServiceMock(); _mergeController = new MergeController(_mergeService, _permissionService); } @@ -54,16 +56,39 @@ public void BlacklistAddTest() // Add two Lists of wordIds. _ = _mergeController.BlacklistAdd(ProjId, wordIdsA).Result; - var result = _mergeBlacklistRepo.GetAllEntries(ProjId).Result; + var result = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result.First().WordIds, Is.EqualTo(wordIdsA)); _ = _mergeController.BlacklistAdd(ProjId, wordIdsB).Result; - result = _mergeBlacklistRepo.GetAllEntries(ProjId).Result; + result = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(result, Has.Count.EqualTo(2)); // Add a List of wordIds that contains both previous lists. _ = _mergeController.BlacklistAdd(ProjId, wordIdsC).Result; - result = _mergeBlacklistRepo.GetAllEntries(ProjId).Result; + result = _mergeBlacklistRepo.GetAllSets(ProjId).Result; + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().WordIds, Is.EqualTo(wordIdsC)); + } + + [Test] + public void GreylistAddTest() + { + var wordIdsA = new List { "1", "2" }; + var wordIdsB = new List { "3", "1" }; + var wordIdsC = new List { "1", "2", "3" }; + + // Add two Lists of wordIds. + _ = _mergeController.GraylistAdd(ProjId, wordIdsA).Result; + var result = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().WordIds, Is.EqualTo(wordIdsA)); + _ = _mergeController.GraylistAdd(ProjId, wordIdsB).Result; + result = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(result, Has.Count.EqualTo(2)); + + // Add a List of wordIds that contains both previous lists. + _ = _mergeController.GraylistAdd(ProjId, wordIdsC).Result; + result = _mergeGraylistRepo.GetAllSets(ProjId).Result; Assert.That(result, Has.Count.EqualTo(1)); Assert.That(result.First().WordIds, Is.EqualTo(wordIdsC)); } diff --git a/Backend.Tests/Helper/DuplicateFinderTests.cs b/Backend.Tests/Helper/DuplicateFinderTests.cs index bd7c551eee..cc207fb516 100644 --- a/Backend.Tests/Helper/DuplicateFinderTests.cs +++ b/Backend.Tests/Helper/DuplicateFinderTests.cs @@ -12,7 +12,7 @@ public class DuplicateFinderTests { private DuplicateFinder _dupFinder = null!; private List _frontier = null!; - private Func, Task> _isInBlacklist = null!; + private Func, Task> _isUnavailableSet = null!; private const int MaxInList = 4; private const int MaxLists = 3; @@ -26,7 +26,7 @@ public void Setup() { _dupFinder = new DuplicateFinder(MaxInList, MaxLists, MaxScore); _frontier = new List(); - _isInBlacklist = _ => Task.FromResult(false); + _isUnavailableSet = _ => Task.FromResult(false); } [Test] @@ -40,7 +40,7 @@ public void GetIdenticalVernToWordTest() _frontier.ElementAt(1).Vernacular = vern; _frontier.ElementAt(2).Vernacular = vern; _frontier.ElementAt(5).Vernacular = vern; - var wordLists = _dupFinder.GetIdenticalVernWords(_frontier, _isInBlacklist).Result; + var wordLists = _dupFinder.GetIdenticalVernWords(_frontier, _isUnavailableSet).Result; Assert.That(wordLists, Has.Count.EqualTo(1)); Assert.That(wordLists.First(), Has.Count.EqualTo(3)); } @@ -50,7 +50,7 @@ public void GetSimilarWordsAndMaxInListAndMaxListsTest() { _frontier = Util.RandomWordList(MaxInList * MaxLists, ProjId); _dupFinder = new DuplicateFinder(MaxInList, MaxLists, NoMaxScore); - var wordLists = _dupFinder.GetSimilarWords(_frontier, _isInBlacklist).Result; + var wordLists = _dupFinder.GetSimilarWords(_frontier, _isUnavailableSet).Result; Assert.That(wordLists, Has.Count.EqualTo(MaxLists)); Assert.That(wordLists.First(), Has.Count.EqualTo(MaxInList)); Assert.That(wordLists.Last(), Has.Count.EqualTo(MaxInList)); @@ -63,7 +63,7 @@ public void GetSimilarWordsAndMaxScoreTest() // Ensure at least one set of similar words, in case MaxScore is too low. _frontier.Last().Vernacular = _frontier.First().Vernacular; - var wordLists = _dupFinder.GetSimilarWords(_frontier, _isInBlacklist).Result; + var wordLists = _dupFinder.GetSimilarWords(_frontier, _isUnavailableSet).Result; var firstList = wordLists.First(); var firstMin = _dupFinder.GetWordScore(firstList.First(), firstList.ElementAt(1)); var firstMax = _dupFinder.GetWordScore(firstList.First(), firstList.Last()); @@ -87,13 +87,13 @@ public void GetSimilarWordsAndMaxScoreTest() } [Test] - public void GetSimilarWordsBlacklistTest() + public void GetSimilarWordsBlacklistOrGraylistTest() { _frontier = Util.RandomWordList(MaxInList + 1, ProjId); - // Make sure the first set only is blacklisted, so all but the first word end up in a lone list. - _isInBlacklist = wordList => Task.FromResult(wordList.First() == _frontier.First().Vernacular); + // Make sure the first set only is black/gray-listed, so all but the first word end up in a lone list. + _isUnavailableSet = wordList => Task.FromResult(wordList.First() == _frontier.First().Vernacular); _dupFinder = new DuplicateFinder(MaxInList, MaxLists, NoMaxScore); - var wordLists = _dupFinder.GetSimilarWords(_frontier, _isInBlacklist).Result; + var wordLists = _dupFinder.GetSimilarWords(_frontier, _isUnavailableSet).Result; Assert.That(wordLists, Has.Count.EqualTo(1)); Assert.That(wordLists.First(), Has.Count.EqualTo(MaxInList)); } diff --git a/Backend.Tests/Mocks/MergeBlacklistRepositoryMock.cs b/Backend.Tests/Mocks/MergeBlacklistRepositoryMock.cs index 1e43269ba7..2f81c3e0fc 100644 --- a/Backend.Tests/Mocks/MergeBlacklistRepositoryMock.cs +++ b/Backend.Tests/Mocks/MergeBlacklistRepositoryMock.cs @@ -10,14 +10,14 @@ namespace Backend.Tests.Mocks { public class MergeBlacklistRepositoryMock : IMergeBlacklistRepository { - private readonly List _mergeBlacklist; + private readonly List _mergeBlacklist; public MergeBlacklistRepositoryMock() { - _mergeBlacklist = new List(); + _mergeBlacklist = new List(); } - public Task> GetAllEntries(string projectId, string? userId = null) + public Task> GetAllSets(string projectId, string? userId = null) { var cloneList = _mergeBlacklist.Select(e => e.Clone()).ToList(); var enumerable = userId is null ? @@ -26,27 +26,27 @@ public Task> GetAllEntries(string projectId, string? u return Task.FromResult(enumerable.ToList()); } - public Task GetEntry(string projectId, string entryId) + public Task GetSet(string projectId, string entryId) { try { var foundMergeBlacklist = _mergeBlacklist.Single(entry => entry.Id == entryId); - return Task.FromResult(foundMergeBlacklist.Clone()); + return Task.FromResult(foundMergeBlacklist.Clone()); } catch (InvalidOperationException) { - return Task.FromResult(null); + return Task.FromResult(null); } } - public Task Create(MergeBlacklistEntry blacklistEntry) + public Task Create(MergeWordSet wordSetEntry) { - blacklistEntry.Id = Guid.NewGuid().ToString(); - _mergeBlacklist.Add(blacklistEntry.Clone()); - return Task.FromResult(blacklistEntry.Clone()); + wordSetEntry.Id = Guid.NewGuid().ToString(); + _mergeBlacklist.Add(wordSetEntry.Clone()); + return Task.FromResult(wordSetEntry.Clone()); } - public Task DeleteAllEntries(string projectId) + public Task DeleteAllSets(string projectId) { _mergeBlacklist.Clear(); return Task.FromResult(true); @@ -58,17 +58,17 @@ public Task Delete(string projectId, string entryId) return Task.FromResult(_mergeBlacklist.Remove(foundMergeBlacklist)); } - public Task Update(MergeBlacklistEntry blacklistEntry) + public Task Update(MergeWordSet wordSetEntry) { var foundEntry = _mergeBlacklist.Single( - e => e.ProjectId == blacklistEntry.ProjectId && e.Id == blacklistEntry.Id); + e => e.ProjectId == wordSetEntry.ProjectId && e.Id == wordSetEntry.Id); var success = _mergeBlacklist.Remove(foundEntry); if (!success) { return Task.FromResult(ResultOfUpdate.NotFound); } - _mergeBlacklist.Add(blacklistEntry.Clone()); + _mergeBlacklist.Add(wordSetEntry.Clone()); return Task.FromResult(ResultOfUpdate.Updated); } } diff --git a/Backend.Tests/Mocks/MergeGraylistRepositoryMock.cs b/Backend.Tests/Mocks/MergeGraylistRepositoryMock.cs new file mode 100644 index 0000000000..edfaba57eb --- /dev/null +++ b/Backend.Tests/Mocks/MergeGraylistRepositoryMock.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; + +namespace Backend.Tests.Mocks +{ + public class MergeGraylistRepositoryMock : IMergeGraylistRepository + { + private readonly List _mergeGraylist; + + public MergeGraylistRepositoryMock() + { + _mergeGraylist = new List(); + } + + public Task> GetAllSets(string projectId, string? userId = null) + { + var cloneList = _mergeGraylist.Select(e => e.Clone()).ToList(); + var enumerable = userId is null ? + cloneList.Where(e => e.ProjectId == projectId) : + cloneList.Where(e => e.ProjectId == projectId && e.UserId == userId); + return Task.FromResult(enumerable.ToList()); + } + + public Task GetSet(string projectId, string entryId) + { + try + { + var foundMergeGraylist = _mergeGraylist.Single(entry => entry.Id == entryId); + return Task.FromResult(foundMergeGraylist.Clone()); + } + catch (InvalidOperationException) + { + return Task.FromResult(null); + } + } + + public Task Create(MergeWordSet wordSetEntry) + { + wordSetEntry.Id = Guid.NewGuid().ToString(); + _mergeGraylist.Add(wordSetEntry.Clone()); + return Task.FromResult(wordSetEntry.Clone()); + } + + public Task DeleteAllSets(string projectId) + { + _mergeGraylist.Clear(); + return Task.FromResult(true); + } + + public Task Delete(string projectId, string entryId) + { + var foundMergeGraylist = _mergeGraylist.Single(entry => entry.Id == entryId); + return Task.FromResult(_mergeGraylist.Remove(foundMergeGraylist)); + } + + public Task Update(MergeWordSet wordSetEntry) + { + var foundEntry = _mergeGraylist.Single( + e => e.ProjectId == wordSetEntry.ProjectId && e.Id == wordSetEntry.Id); + var success = _mergeGraylist.Remove(foundEntry); + if (!success) + { + return Task.FromResult(ResultOfUpdate.NotFound); + } + + _mergeGraylist.Add(wordSetEntry.Clone()); + return Task.FromResult(ResultOfUpdate.Updated); + } + } +} diff --git a/Backend.Tests/Models/MergeBlacklistEntryTests.cs b/Backend.Tests/Models/MergeWordSetTests.cs similarity index 77% rename from Backend.Tests/Models/MergeBlacklistEntryTests.cs rename to Backend.Tests/Models/MergeWordSetTests.cs index df040bf520..8c943d5b4d 100644 --- a/Backend.Tests/Models/MergeBlacklistEntryTests.cs +++ b/Backend.Tests/Models/MergeWordSetTests.cs @@ -4,18 +4,18 @@ namespace Backend.Tests.Models { - public class MergeBlacklistEntryTests + public class MergeWordSetTests { - private const string EntryId = "MergeBlacklistEntryTestId"; - private const string ProjId = "MergeBlacklistEntryTestProjectId"; - private const string UserId = "MergeBlacklistEntryTestUserId"; + private const string EntryId = "MergeWordSetTestId"; + private const string ProjId = "MergeWordSetTestProjectId"; + private const string UserId = "MergeWordSetTestUserId"; private readonly List _wordIds = new() { "word1", "word2" }; private readonly List _wordIdsReversed = new() { "word2", "word1" }; [Test] public void TestClone() { - var entryA = new MergeBlacklistEntry + var entryA = new MergeWordSet { Id = EntryId, ProjectId = ProjId, @@ -29,14 +29,14 @@ public void TestClone() [Test] public void TestEquals() { - var entryA = new MergeBlacklistEntry + var entryA = new MergeWordSet { Id = EntryId, ProjectId = ProjId, UserId = UserId, WordIds = _wordIds }; - var entryB = new MergeBlacklistEntry + var entryB = new MergeWordSet { Id = EntryId, ProjectId = ProjId, @@ -49,8 +49,8 @@ public void TestEquals() [Test] public void TestEqualsFalse() { - var entryA = new MergeBlacklistEntry(); - var entryB = new MergeBlacklistEntry(); + var entryA = new MergeWordSet(); + var entryB = new MergeWordSet(); entryA.Id = EntryId; Assert.That(entryA.Equals(entryB), Is.False); @@ -70,21 +70,21 @@ public void TestEqualsFalse() [Test] public void TestEqualsNull() { - var edit = new MergeBlacklistEntry { ProjectId = ProjId }; + var edit = new MergeWordSet { ProjectId = ProjId }; Assert.That(edit.Equals(null), Is.False); } [Test] public void TestHashCode() { - var entryA = new MergeBlacklistEntry + var entryA = new MergeWordSet { Id = EntryId, ProjectId = ProjId, UserId = UserId, WordIds = _wordIdsReversed }; - var entryB = new MergeBlacklistEntry + var entryB = new MergeWordSet { Id = "DifferentTestId", ProjectId = ProjId, diff --git a/Backend.Tests/Services/MergeServiceTests.cs b/Backend.Tests/Services/MergeServiceTests.cs index a4fd1bb8d8..2f0580975c 100644 --- a/Backend.Tests/Services/MergeServiceTests.cs +++ b/Backend.Tests/Services/MergeServiceTests.cs @@ -11,6 +11,7 @@ namespace Backend.Tests.Services public class MergeServiceTests { private IMergeBlacklistRepository _mergeBlacklistRepo = null!; + private IMergeGraylistRepository _mergeGraylistRepo = null!; private IWordRepository _wordRepo = null!; private IWordService _wordService = null!; private IMergeService _mergeService = null!; @@ -22,9 +23,10 @@ public class MergeServiceTests public void Setup() { _mergeBlacklistRepo = new MergeBlacklistRepositoryMock(); + _mergeGraylistRepo = new MergeGraylistRepositoryMock(); _wordRepo = new WordRepositoryMock(); _wordService = new WordService(_wordRepo); - _mergeService = new MergeService(_mergeBlacklistRepo, _wordRepo, _wordService); + _mergeService = new MergeService(_mergeBlacklistRepo, _mergeGraylistRepo, _wordRepo, _wordService); } [Test] @@ -197,31 +199,39 @@ public void UndoMergeMultiChildTest() [Test] public void AddMergeToBlacklistTest() { - _ = _mergeBlacklistRepo.DeleteAllEntries(ProjId).Result; + _ = _mergeBlacklistRepo.DeleteAllSets(ProjId).Result; + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; var wordIds = new List { "1", "2" }; + + // Adding to blacklist should clear from graylist + _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result; + Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Is.Not.Empty); + _ = _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds).Result; - var blacklist = _mergeBlacklistRepo.GetAllEntries(ProjId).Result; + var blacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(blacklist, Has.Count.EqualTo(1)); - var expectedEntry = new MergeBlacklistEntry { ProjectId = ProjId, UserId = UserId, WordIds = wordIds }; + var expectedEntry = new MergeWordSet { ProjectId = ProjId, UserId = UserId, WordIds = wordIds }; Assert.That(expectedEntry.ContentEquals(blacklist.First()), Is.True); + + Assert.That(_mergeGraylistRepo.GetAllSets(ProjId).Result, Is.Empty); } [Test] public void AddMergeToBlacklistErrorTest() { - _ = _mergeBlacklistRepo.DeleteAllEntries(ProjId).Result; + _ = _mergeBlacklistRepo.DeleteAllSets(ProjId).Result; var wordIds0 = new List(); var wordIds1 = new List { "1" }; Assert.That( - async () => { await _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds0); }, Throws.TypeOf()); + async () => { await _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds0); }, Throws.TypeOf()); Assert.That( - async () => { await _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds1); }, Throws.TypeOf()); + async () => { await _mergeService.AddToMergeBlacklist(ProjId, UserId, wordIds1); }, Throws.TypeOf()); } [Test] public void IsInMergeBlacklistTest() { - _ = _mergeBlacklistRepo.DeleteAllEntries(ProjId).Result; + _ = _mergeBlacklistRepo.DeleteAllSets(ProjId).Result; var wordIds = new List { "1", "2", "3" }; var subWordIds = new List { "3", "2" }; @@ -233,26 +243,26 @@ public void IsInMergeBlacklistTest() [Test] public void IsInMergeBlacklistErrorTest() { - _ = _mergeBlacklistRepo.DeleteAllEntries(ProjId).Result; + _ = _mergeBlacklistRepo.DeleteAllSets(ProjId).Result; var wordIds0 = new List(); var wordIds1 = new List { "1" }; Assert.That( - async () => { await _mergeService.IsInMergeBlacklist(ProjId, wordIds0); }, Throws.TypeOf()); + async () => { await _mergeService.IsInMergeBlacklist(ProjId, wordIds0); }, Throws.TypeOf()); Assert.That( - async () => { await _mergeService.IsInMergeBlacklist(ProjId, wordIds1); }, Throws.TypeOf()); + async () => { await _mergeService.IsInMergeBlacklist(ProjId, wordIds1); }, Throws.TypeOf()); } [Test] public void UpdateMergeBlacklistTest() { - var entryA = new MergeBlacklistEntry + var entryA = new MergeWordSet { Id = "A", ProjectId = ProjId, UserId = UserId, WordIds = new List { "1", "2", "3" } }; - var entryB = new MergeBlacklistEntry + var entryB = new MergeWordSet { Id = "B", ProjectId = ProjId, @@ -263,7 +273,7 @@ public void UpdateMergeBlacklistTest() _ = _mergeBlacklistRepo.Create(entryA); _ = _mergeBlacklistRepo.Create(entryB); - var oldBlacklist = _mergeBlacklistRepo.GetAllEntries(ProjId).Result; + var oldBlacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(oldBlacklist, Has.Count.EqualTo(2)); // Make sure all wordIds are in the frontier EXCEPT 1. @@ -280,9 +290,133 @@ public void UpdateMergeBlacklistTest() Assert.That(updatedEntriesCount, Is.EqualTo(2)); // The only blacklistEntry with at least two ids in the frontier is A. - var newBlacklist = _mergeBlacklistRepo.GetAllEntries(ProjId).Result; + var newBlacklist = _mergeBlacklistRepo.GetAllSets(ProjId).Result; Assert.That(newBlacklist, Has.Count.EqualTo(1)); Assert.That(newBlacklist.First().WordIds, Is.EqualTo(new List { "2", "3" })); } + + [Test] + public void AddMergeToGraylistTest() + { + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; + var wordIds = new List { "1", "2" }; + _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result; + var graylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(graylist, Has.Count.EqualTo(1)); + var expectedEntry = new MergeWordSet { ProjectId = ProjId, UserId = UserId, WordIds = wordIds }; + Assert.That(expectedEntry.ContentEquals(graylist.First()), Is.True); + } + + [Test] + public void AddMergeToGraylistErrorTest() + { + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; + var wordIds = new List(); + var wordIds1 = new List { "1" }; + Assert.That( + async () => { await _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds); }, + Throws.TypeOf()); + Assert.That( + async () => { await _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds1); }, + Throws.TypeOf()); + } + + [Test] + public void RemoveFromMergeGraylistTest() + { + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; + var wordIds12 = new List { "1", "2" }; + var wordIds13 = new List { "1", "3" }; + _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds12).Result; + _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds13).Result; + var graylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(graylist, Has.Count.EqualTo(2)); + var wordIds123 = new List { "1", "2", "3" }; + var removed = _mergeService.RemoveFromMergeGraylist(ProjId, UserId, wordIds123).Result; + Assert.That(removed, Has.Count.EqualTo(2)); + graylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(graylist, Is.Empty); + } + + [Test] + public void RemoveFromMergeGraylistErrorTest() + { + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; + var wordIds = new List(); + var wordIds1 = new List { "1" }; + Assert.That( + async () => { await _mergeService.RemoveFromMergeGraylist(ProjId, UserId, wordIds); }, + Throws.TypeOf()); + Assert.That( + async () => { await _mergeService.RemoveFromMergeGraylist(ProjId, UserId, wordIds1); }, + Throws.TypeOf()); + } + + [Test] + public void IsInMergeGraylistTest() + { + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; + var wordIds = new List { "1", "2", "3" }; + var subWordIds = new List { "3", "2" }; + + Assert.That(_mergeService.IsInMergeGraylist(ProjId, subWordIds).Result, Is.False); + _ = _mergeService.AddToMergeGraylist(ProjId, UserId, wordIds).Result; + Assert.That(_mergeService.IsInMergeGraylist(ProjId, subWordIds).Result, Is.True); + } + + [Test] + public void IsInMergeGraylistErrorTest() + { + _ = _mergeGraylistRepo.DeleteAllSets(ProjId).Result; + var wordIds0 = new List(); + var wordIds1 = new List { "1" }; + Assert.That( + async () => { await _mergeService.IsInMergeGraylist(ProjId, wordIds0); }, Throws.TypeOf()); + Assert.That( + async () => { await _mergeService.IsInMergeGraylist(ProjId, wordIds1); }, Throws.TypeOf()); + } + + [Test] + public void UpdateMergeGraylistTest() + { + var entryA = new MergeWordSet + { + Id = "A", + ProjectId = ProjId, + UserId = UserId, + WordIds = new List { "1", "2", "3" } + }; + var entryB = new MergeWordSet + { + Id = "B", + ProjectId = ProjId, + UserId = UserId, + WordIds = new List { "1", "4" } + }; + + _ = _mergeGraylistRepo.Create(entryA); + _ = _mergeGraylistRepo.Create(entryB); + + var oldGraylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(oldGraylist, Has.Count.EqualTo(2)); + + // Make sure all wordIds are in the frontier EXCEPT 1. + var frontier = new List + { + new() {Id = "2", ProjectId = ProjId}, + new() {Id = "3", ProjectId = ProjId}, + new() {Id = "4", ProjectId = ProjId} + }; + _ = _wordRepo.AddFrontier(frontier).Result; + + // All entries affected. + var updatedEntriesCount = _mergeService.UpdateMergeGraylist(ProjId).Result; + Assert.That(updatedEntriesCount, Is.EqualTo(2)); + + // The only graylistEntry with at least two ids in the frontier is A. + var newGraylist = _mergeGraylistRepo.GetAllSets(ProjId).Result; + Assert.That(newGraylist, Has.Count.EqualTo(1)); + Assert.That(newGraylist.First().WordIds, Is.EqualTo(new List { "2", "3" })); + } } } diff --git a/Backend/Contexts/MergeBlacklistContext.cs b/Backend/Contexts/MergeBlacklistContext.cs index 5150b5264e..f977bf6d70 100644 --- a/Backend/Contexts/MergeBlacklistContext.cs +++ b/Backend/Contexts/MergeBlacklistContext.cs @@ -17,7 +17,7 @@ public MergeBlacklistContext(IOptions options) _db = client.GetDatabase(options.Value.CombineDatabase); } - public IMongoCollection MergeBlacklist => _db.GetCollection( + public IMongoCollection MergeBlacklist => _db.GetCollection( "MergeBlacklistCollection"); } } diff --git a/Backend/Contexts/MergeGraylistContext.cs b/Backend/Contexts/MergeGraylistContext.cs new file mode 100644 index 0000000000..d9ea381829 --- /dev/null +++ b/Backend/Contexts/MergeGraylistContext.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace BackendFramework.Contexts +{ + [ExcludeFromCodeCoverage] + public class MergeGraylistContext : IMergeGraylistContext + { + private readonly IMongoDatabase _db; + + public MergeGraylistContext(IOptions options) + { + var client = new MongoClient(options.Value.ConnectionString); + _db = client.GetDatabase(options.Value.CombineDatabase); + } + + public IMongoCollection MergeGraylist => _db.GetCollection( + "MergeGraylistCollection"); + } +} diff --git a/Backend/Controllers/MergeController.cs b/Backend/Controllers/MergeController.cs index 43739a1944..7c1185d5e3 100644 --- a/Backend/Controllers/MergeController.cs +++ b/Backend/Controllers/MergeController.cs @@ -78,6 +78,22 @@ public async Task BlacklistAdd(string projectId, [FromBody, BindR return Ok(blacklistEntry.WordIds); } + /// Add List of Ids to merge graylist + /// List of word ids added to graylist. + [HttpPut("graylist/add", Name = "graylistAdd")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + public async Task GraylistAdd(string projectId, [FromBody, BindRequired] List wordIds) + { + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.MergeAndReviewEntries)) + { + return Forbid(); + } + + var userId = _permissionService.GetUserId(HttpContext); + var graylistEntry = await _mergeService.AddToMergeGraylist(projectId, userId, wordIds); + return Ok(graylistEntry.WordIds); + } + /// Get lists of potential duplicates for merging. /// Id of project in which to search the frontier for potential duplicates. /// Max number of words allowed within a list of potential duplicates. @@ -95,8 +111,27 @@ public async Task GetPotentialDuplicates( } await _mergeService.UpdateMergeBlacklist(projectId); - return Ok( - await _mergeService.GetPotentialDuplicates(projectId, maxInList, maxLists, userId)); + return Ok(await _mergeService.GetPotentialDuplicates(projectId, maxInList, maxLists, userId)); } + + /// Get lists of graylist entries. + /// Id of project in which to search the frontier for potential duplicates. + /// Max number of lists of potential duplicates. + /// Id of user whose merge graylist is to be used. + /// List of Lists of s. + [HttpGet("getgraylist/{maxLists}/{userId}", Name = "GetGraylistEntries")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List>))] + public async Task getGraylistEntries( + string projectId, int maxLists, string userId) + { + if (!await _permissionService.HasProjectPermission(HttpContext, Permission.MergeAndReviewEntries)) + { + return Forbid(); + } + + await _mergeService.UpdateMergeGraylist(projectId); + return Ok(await _mergeService.GetGraylistEntries(projectId, maxLists, userId)); + } + } } diff --git a/Backend/Helper/DuplicateFinder.cs b/Backend/Helper/DuplicateFinder.cs index 70e39fb0b8..bc6fc2678c 100644 --- a/Backend/Helper/DuplicateFinder.cs +++ b/Backend/Helper/DuplicateFinder.cs @@ -27,7 +27,7 @@ public DuplicateFinder(int maxInList, int maxLists, int maxScore) /// each with multiple s having a common Vernacular. /// public async Task>> GetIdenticalVernWords( - List collection, Func, Task> isInBlacklist) + List collection, Func, Task> isUnavailableSet) { var wordLists = new List> { Capacity = _maxLists }; while (collection.Count > 0 && wordLists.Count < _maxLists) @@ -40,10 +40,10 @@ public async Task>> GetIdenticalVernWords( continue; } - // Check if set is in blacklist. + // Check if set is in blacklist or graylist. var ids = new List { word.Id }; ids.AddRange(similarWords.Select(w => w.Id)); - if (await isInBlacklist(ids)) + if (await isUnavailableSet(ids)) { continue; } @@ -62,7 +62,7 @@ public async Task>> GetIdenticalVernWords( /// the outer list is ordered by similarity of the first two items in each inner List. /// public async Task>> GetSimilarWords( - List collection, Func, Task> isInBlacklist) + List collection, Func, Task> isUnavailableSet) { double currentMax = _maxScore; var wordLists = new List>> { Capacity = _maxLists + 1 }; @@ -81,10 +81,10 @@ public async Task>> GetSimilarWords( continue; } - // Check if set is in blacklist. + // Check if set is in blacklist or graylist. var ids = new List { word.Id }; ids.AddRange(similarWords.Select(w => w.Item2.Id)); - if (await isInBlacklist(ids)) + if (await isUnavailableSet(ids)) { continue; } diff --git a/Backend/Interfaces/IMergeBlacklistContext.cs b/Backend/Interfaces/IMergeBlacklistContext.cs index 985d850ad6..b89202f385 100644 --- a/Backend/Interfaces/IMergeBlacklistContext.cs +++ b/Backend/Interfaces/IMergeBlacklistContext.cs @@ -5,6 +5,6 @@ namespace BackendFramework.Interfaces { public interface IMergeBlacklistContext { - IMongoCollection MergeBlacklist { get; } + IMongoCollection MergeBlacklist { get; } } } diff --git a/Backend/Interfaces/IMergeBlacklistRepository.cs b/Backend/Interfaces/IMergeBlacklistRepository.cs index 4696b1793d..8baef091dc 100644 --- a/Backend/Interfaces/IMergeBlacklistRepository.cs +++ b/Backend/Interfaces/IMergeBlacklistRepository.cs @@ -7,11 +7,11 @@ namespace BackendFramework.Interfaces { public interface IMergeBlacklistRepository { - Task> GetAllEntries(string projectId, string? userId = null); - Task GetEntry(string projectId, string entryId); - Task Create(MergeBlacklistEntry blacklistEntry); + Task> GetAllSets(string projectId, string? userId = null); + Task GetSet(string projectId, string entryId); + Task Create(MergeWordSet wordSetEntry); Task Delete(string projectId, string entryId); - Task DeleteAllEntries(string projectId); - Task Update(MergeBlacklistEntry blacklistEntry); + Task DeleteAllSets(string projectId); + Task Update(MergeWordSet wordSetEntry); } } diff --git a/Backend/Interfaces/IMergeGraylistContext.cs b/Backend/Interfaces/IMergeGraylistContext.cs new file mode 100644 index 0000000000..ad5fdbdaa3 --- /dev/null +++ b/Backend/Interfaces/IMergeGraylistContext.cs @@ -0,0 +1,10 @@ +using BackendFramework.Models; +using MongoDB.Driver; + +namespace BackendFramework.Interfaces +{ + public interface IMergeGraylistContext + { + IMongoCollection MergeGraylist { get; } + } +} diff --git a/Backend/Interfaces/IMergeGraylistRepository.cs b/Backend/Interfaces/IMergeGraylistRepository.cs new file mode 100644 index 0000000000..d07fc1a0a9 --- /dev/null +++ b/Backend/Interfaces/IMergeGraylistRepository.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Models; + +namespace BackendFramework.Interfaces +{ + public interface IMergeGraylistRepository + { + Task> GetAllSets(string projectId, string? userId = null); + Task GetSet(string projectId, string entryId); + Task Create(MergeWordSet wordSetEntry); + Task Delete(string projectId, string entryId); + Task DeleteAllSets(string projectId); + Task Update(MergeWordSet wordSetEntry); + } +} diff --git a/Backend/Interfaces/IMergeService.cs b/Backend/Interfaces/IMergeService.cs index 8e679408ed..71e6562588 100644 --- a/Backend/Interfaces/IMergeService.cs +++ b/Backend/Interfaces/IMergeService.cs @@ -8,10 +8,15 @@ public interface IMergeService { Task> Merge(string projectId, List mergeWordsList); Task UndoMerge(string projectId, MergeUndoIds ids); - Task AddToMergeBlacklist(string projectId, string userId, List wordIds); + Task AddToMergeBlacklist(string projectId, string userId, List wordIds); + Task AddToMergeGraylist(string projectId, string userId, List wordIds); + Task> RemoveFromMergeGraylist(string projectId, string userId, List wordIds); Task IsInMergeBlacklist(string projectId, List wordIds, string? userId = null); + Task IsInMergeGraylist(string projectId, List wordIds, string? userId = null); Task UpdateMergeBlacklist(string projectId); + Task UpdateMergeGraylist(string projectId); Task>> GetPotentialDuplicates( string projectId, int maxInList, int maxLists, string? userId = null); + Task>> GetGraylistEntries(string projectId, int maxLists, string? userId = null); } } diff --git a/Backend/Models/MergeBlacklistEntry.cs b/Backend/Models/MergeWordSet.cs similarity index 84% rename from Backend/Models/MergeBlacklistEntry.cs rename to Backend/Models/MergeWordSet.cs index 20a157288c..f88dcfb594 100644 --- a/Backend/Models/MergeBlacklistEntry.cs +++ b/Backend/Models/MergeWordSet.cs @@ -7,7 +7,7 @@ namespace BackendFramework.Models { /// A List of wordIds to avoid in future merges. - public class MergeBlacklistEntry + public class MergeWordSet { [BsonId] [BsonRepresentation(BsonType.ObjectId)] @@ -22,7 +22,7 @@ public class MergeBlacklistEntry [BsonElement("wordIds")] public List WordIds { get; set; } - public MergeBlacklistEntry() + public MergeWordSet() { Id = ""; ProjectId = ""; @@ -30,9 +30,9 @@ public MergeBlacklistEntry() WordIds = new List(); } - public MergeBlacklistEntry Clone() + public MergeWordSet Clone() { - var clone = new MergeBlacklistEntry + var clone = new MergeWordSet { Id = Id, ProjectId = ProjectId, @@ -46,7 +46,7 @@ public MergeBlacklistEntry Clone() return clone; } - public bool ContentEquals(MergeBlacklistEntry other) + public bool ContentEquals(MergeWordSet other) { return other.ProjectId.Equals(ProjectId, StringComparison.Ordinal) && @@ -57,7 +57,7 @@ public bool ContentEquals(MergeBlacklistEntry other) public override bool Equals(object? obj) { - if (obj is not MergeBlacklistEntry other || GetType() != obj.GetType()) + if (obj is not MergeWordSet other || GetType() != obj.GetType()) { return false; } diff --git a/Backend/Models/UserEdit.cs b/Backend/Models/UserEdit.cs index 3f54a996aa..1008891e86 100644 --- a/Backend/Models/UserEdit.cs +++ b/Backend/Models/UserEdit.cs @@ -119,7 +119,7 @@ public class Edit public Guid Guid { get; set; } #pragma warning restore CA1720 - /// Integer representation of enum + /// Integer representation of enum GoalType in src/types/goals.ts [Required] [BsonElement("goalType")] public int GoalType { get; set; } @@ -177,16 +177,4 @@ public override int GetHashCode() return HashCode.Combine(Guid, GoalType, StepData, Changes); } } - - public enum GoalType - { - CreateCharInv, - ValidateChars, - CreateStrWordInv, - ValidateStrWords, - MergeDups, - SpellcheckGloss, - ViewFind, - HandleFlags - } } diff --git a/Backend/Repositories/MergeBlacklistRepository.cs b/Backend/Repositories/MergeBlacklistRepository.cs index 3099485bff..73fccf91ba 100644 --- a/Backend/Repositories/MergeBlacklistRepository.cs +++ b/Backend/Repositories/MergeBlacklistRepository.cs @@ -9,7 +9,7 @@ namespace BackendFramework.Repositories { - /// Atomic database functions for s. + /// Atomic database functions for s. [ExcludeFromCodeCoverage] public class MergeBlacklistRepository : IMergeBlacklistRepository { @@ -20,8 +20,8 @@ public MergeBlacklistRepository(IMergeBlacklistContext collectionSettings) _mergeBlacklistDatabase = collectionSettings; } - /// Finds all s for specified . - public async Task> GetAllEntries(string projectId, string? userId = null) + /// Finds all s for specified . + public async Task> GetAllSets(string projectId, string? userId = null) { var listFind = userId is null ? _mergeBlacklistDatabase.MergeBlacklist.Find(e => e.ProjectId == projectId) : @@ -29,18 +29,18 @@ public async Task> GetAllEntries(string projectId, str return await listFind.ToListAsync(); } - /// Removes all s for specified . + /// Removes all s for specified . /// A bool: success of operation. - public async Task DeleteAllEntries(string projectId) + public async Task DeleteAllSets(string projectId) { var deleted = await _mergeBlacklistDatabase.MergeBlacklist.DeleteManyAsync(u => u.ProjectId == projectId); return deleted.DeletedCount != 0; } - /// Finds specified for specified . - public async Task GetEntry(string projectId, string entryId) + /// Finds specified for specified . + public async Task GetSet(string projectId, string entryId) { - var filterDef = new FilterDefinitionBuilder(); + var filterDef = new FilterDefinitionBuilder(); var filter = filterDef.And( filterDef.Eq(x => x.ProjectId, projectId), filterDef.Eq(x => x.Id, entryId)); @@ -56,19 +56,19 @@ public async Task DeleteAllEntries(string projectId) } } - /// Adds a . - /// The MergeBlacklistEntry created. - public async Task Create(MergeBlacklistEntry blacklistEntry) + /// Adds a . + /// The MergeWordSet created. + public async Task Create(MergeWordSet wordSetEntry) { - await _mergeBlacklistDatabase.MergeBlacklist.InsertOneAsync(blacklistEntry); - return blacklistEntry; + await _mergeBlacklistDatabase.MergeBlacklist.InsertOneAsync(wordSetEntry); + return wordSetEntry; } - /// Removes specified for specified . + /// Removes specified for specified . /// A bool: success of operation. public async Task Delete(string projectId, string entryId) { - var filterDef = new FilterDefinitionBuilder(); + var filterDef = new FilterDefinitionBuilder(); var filter = filterDef.And( filterDef.Eq(x => x.ProjectId, projectId), filterDef.Eq(x => x.Id, entryId)); @@ -76,15 +76,15 @@ public async Task Delete(string projectId, string entryId) return deleted.DeletedCount > 0; } - /// Updates specified . + /// Updates specified . /// A enum: success of operation. - public async Task Update(MergeBlacklistEntry blacklistEntry) + public async Task Update(MergeWordSet wordSetEntry) { - var filter = Builders.Filter.Eq(x => x.Id, blacklistEntry.Id); - var updateDef = Builders.Update - .Set(x => x.ProjectId, blacklistEntry.ProjectId) - .Set(x => x.UserId, blacklistEntry.UserId) - .Set(x => x.WordIds, blacklistEntry.WordIds); + var filter = Builders.Filter.Eq(x => x.Id, wordSetEntry.Id); + var updateDef = Builders.Update + .Set(x => x.ProjectId, wordSetEntry.ProjectId) + .Set(x => x.UserId, wordSetEntry.UserId) + .Set(x => x.WordIds, wordSetEntry.WordIds); var updateResult = await _mergeBlacklistDatabase.MergeBlacklist.UpdateOneAsync(filter, updateDef); if (!updateResult.IsAcknowledged) diff --git a/Backend/Repositories/MergeGraylistRepository.cs b/Backend/Repositories/MergeGraylistRepository.cs new file mode 100644 index 0000000000..9e793ccfd6 --- /dev/null +++ b/Backend/Repositories/MergeGraylistRepository.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using MongoDB.Driver; + +namespace BackendFramework.Repositories +{ + /// Atomic database functions for s. + [ExcludeFromCodeCoverage] + public class MergeGraylistRepository : IMergeGraylistRepository + { + private readonly IMergeGraylistContext _mergeGraylistDatabase; + + public MergeGraylistRepository(IMergeGraylistContext collectionSettings) + { + _mergeGraylistDatabase = collectionSettings; + } + + /// Finds all s for specified . + public async Task> GetAllSets(string projectId, string? userId = null) + { + var listFind = userId is null ? + _mergeGraylistDatabase.MergeGraylist.Find(e => e.ProjectId == projectId) : + _mergeGraylistDatabase.MergeGraylist.Find(e => e.ProjectId == projectId && e.UserId == userId); + return await listFind.ToListAsync(); + } + + /// Removes all s for specified . + /// A bool: success of operation. + public async Task DeleteAllSets(string projectId) + { + var deleted = await _mergeGraylistDatabase.MergeGraylist.DeleteManyAsync(u => u.ProjectId == projectId); + return deleted.DeletedCount != 0; + } + + /// Finds specified for specified . + public async Task GetSet(string projectId, string entryId) + { + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.And( + filterDef.Eq(x => x.ProjectId, projectId), + filterDef.Eq(x => x.Id, entryId)); + + var graylistEntryList = await _mergeGraylistDatabase.MergeGraylist.FindAsync(filter); + try + { + return await graylistEntryList.FirstAsync(); + } + catch (InvalidOperationException) + { + return null; + } + } + + /// Adds a . + /// The MergeWordSet created. + public async Task Create(MergeWordSet wordSetEntry) + { + await _mergeGraylistDatabase.MergeGraylist.InsertOneAsync(wordSetEntry); + return wordSetEntry; + } + + /// Removes specified for specified . + /// A bool: success of operation. + public async Task Delete(string projectId, string entryId) + { + var filterDef = new FilterDefinitionBuilder(); + var filter = filterDef.And( + filterDef.Eq(x => x.ProjectId, projectId), + filterDef.Eq(x => x.Id, entryId)); + var deleted = await _mergeGraylistDatabase.MergeGraylist.DeleteOneAsync(filter); + return deleted.DeletedCount > 0; + } + + /// Updates specified . + /// A enum: success of operation. + public async Task Update(MergeWordSet wordSetEntry) + { + var filter = Builders.Filter.Eq(x => x.Id, wordSetEntry.Id); + var updateDef = Builders.Update + .Set(x => x.ProjectId, wordSetEntry.ProjectId) + .Set(x => x.UserId, wordSetEntry.UserId) + .Set(x => x.WordIds, wordSetEntry.WordIds); + + var updateResult = await _mergeGraylistDatabase.MergeGraylist.UpdateOneAsync(filter, updateDef); + if (!updateResult.IsAcknowledged) + { + return ResultOfUpdate.NotFound; + } + if (updateResult.ModifiedCount > 0) + { + return ResultOfUpdate.Updated; + } + return ResultOfUpdate.NoChange; + } + } +} diff --git a/Backend/Services/MergeService.cs b/Backend/Services/MergeService.cs index b70758b2b3..9e5903dbd0 100644 --- a/Backend/Services/MergeService.cs +++ b/Backend/Services/MergeService.cs @@ -13,13 +13,15 @@ namespace BackendFramework.Services public class MergeService : IMergeService { private readonly IMergeBlacklistRepository _mergeBlacklistRepo; + private readonly IMergeGraylistRepository _mergeGraylistRepo; private readonly IWordRepository _wordRepo; private readonly IWordService _wordService; - public MergeService( - IMergeBlacklistRepository mergeBlacklistRepo, IWordRepository wordRepo, IWordService wordService) + public MergeService(IMergeBlacklistRepository mergeBlacklistRepo, IMergeGraylistRepository mergeGraylistRepo, + IWordRepository wordRepo, IWordService wordService) { _mergeBlacklistRepo = mergeBlacklistRepo; + _mergeGraylistRepo = mergeGraylistRepo; _wordRepo = wordRepo; _wordService = wordService; } @@ -115,17 +117,19 @@ public async Task UndoMerge(string projectId, MergeUndoIds ids) } /// Adds a List of wordIds to MergeBlacklist of specified . - /// Throws when wordIds has count less than 2. - /// The created. - public async Task AddToMergeBlacklist( + /// Throws when wordIds has count less than 2. + /// The created. + public async Task AddToMergeBlacklist( string projectId, string userId, List wordIds) { if (wordIds.Count < 2) { - throw new InvalidBlacklistEntryException("Cannot blacklist a list of fewer than 2 wordIds."); + throw new InvalidMergeWordSetException("Cannot blacklist a list of fewer than 2 wordIds."); } - // When we switch from individual to common blacklist, the userId argument here should be removed. - var blacklist = await _mergeBlacklistRepo.GetAllEntries(projectId, userId); + + // It's possible to add a superset of an existing blacklist entry, + // so we cleanup by removing all entries fully contained in the new entry. + var blacklist = await _mergeBlacklistRepo.GetAllSets(projectId, userId); foreach (var entry in blacklist) { if (entry.WordIds.All(wordIds.Contains)) @@ -133,20 +137,71 @@ public async Task AddToMergeBlacklist( await _mergeBlacklistRepo.Delete(projectId, entry.Id); } } - var newEntry = new MergeBlacklistEntry { ProjectId = projectId, UserId = userId, WordIds = wordIds }; + await RemoveFromMergeGraylist(projectId, userId, wordIds); + var newEntry = new MergeWordSet { ProjectId = projectId, UserId = userId, WordIds = wordIds }; return await _mergeBlacklistRepo.Create(newEntry); } + /// 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) + { + if (wordIds.Count < 2) + { + throw new InvalidMergeWordSetException("Cannot graylist a list of fewer than 2 wordIds."); + } + + // It's possible to add a superset of an existing graylist entry, + // so we cleanup by removing all entries fully contained in the new entry. + var graylist = await _mergeGraylistRepo.GetAllSets(projectId, userId); + foreach (var entry in graylist) + { + if (entry.WordIds.All(wordIds.Contains)) + { + await _mergeGraylistRepo.Delete(projectId, entry.Id); + } + } + var newEntry = new MergeWordSet { ProjectId = projectId, UserId = userId, WordIds = wordIds }; + return await _mergeGraylistRepo.Create(newEntry); + } + + /// Remove a List of wordIds from MergeGraylist of specified . + /// Throws when wordIds has count less than 2. + /// List of removed ids. + public async Task> RemoveFromMergeGraylist( + string projectId, string userId, List wordIds) + { + if (wordIds.Count < 2) + { + throw new InvalidMergeWordSetException("Cannot have a graylist entry with fewer than 2 wordIds."); + } + + // Remove all graylist entries fully contained in the input List. + var graylist = await _mergeGraylistRepo.GetAllSets(projectId, userId); + var removed = new List(); + foreach (var entry in graylist) + { + if (entry.WordIds.All(wordIds.Contains)) + { + await _mergeGraylistRepo.Delete(projectId, entry.Id); + removed.Add(entry.Id); + } + } + return removed; + } + /// Check if List of wordIds is in MergeBlacklist for specified . - /// Throws when wordIds has count less than 2. + /// Throws when wordIds has count less than 2. /// A bool, true if in the blacklist. public async Task IsInMergeBlacklist(string projectId, List wordIds, string? userId = null) { if (wordIds.Count < 2) { - throw new InvalidBlacklistEntryException("Cannot blacklist a list of fewer than 2 wordIds."); + throw new InvalidMergeWordSetException("Cannot blacklist a list of fewer than 2 wordIds."); } - var blacklist = await _mergeBlacklistRepo.GetAllEntries(projectId, userId); + var blacklist = await _mergeBlacklistRepo.GetAllSets(projectId, userId); foreach (var entry in blacklist) { if (wordIds.All(entry.WordIds.Contains)) @@ -157,15 +212,35 @@ public async Task IsInMergeBlacklist(string projectId, List wordId return false; } + /// Check if List of wordIds is in MergeGraylist for specified . + /// Throws when wordIds has count less than 2. + /// A bool, true if in the graylist. + public async Task IsInMergeGraylist(string projectId, List wordIds, string? userId = null) + { + if (wordIds.Count < 2) + { + throw new InvalidMergeWordSetException("Cannot graylist a list of fewer than 2 wordIds."); + } + var graylist = await _mergeGraylistRepo.GetAllSets(projectId, userId); + foreach (var entry in graylist) + { + if (wordIds.All(entry.WordIds.Contains)) + { + return true; + } + } + return false; + } + /// /// Update merge blacklist for specified to current frontier. /// Remove from all blacklist entries any ids for words no longer in the frontier /// and delete entries that no longer have at least two wordIds. /// - /// Number of s updated. + /// Number of s updated. public async Task UpdateMergeBlacklist(string projectId) { - var oldBlacklist = await _mergeBlacklistRepo.GetAllEntries(projectId); + var oldBlacklist = await _mergeBlacklistRepo.GetAllSets(projectId); if (oldBlacklist.Count == 0) { return 0; @@ -194,6 +269,61 @@ public async Task UpdateMergeBlacklist(string projectId) return updateCount; } + /// + /// Update merge graylist for specified to current frontier. + /// Remove from all graylist entries any ids for words no longer in the frontier + /// and delete entries that no longer have at least two wordIds. + /// + /// Number of s updated. + public async Task UpdateMergeGraylist(string projectId) + { + var oldGraylist = await _mergeGraylistRepo.GetAllSets(projectId); + if (oldGraylist.Count == 0) + { + return 0; + } + var frontierWordIds = (await _wordRepo.GetFrontier(projectId)).Select(word => word.Id); + var updateCount = 0; + foreach (var entry in oldGraylist) + { + var newIds = entry.WordIds.Where(id => frontierWordIds.Contains(id)).ToList(); + if (newIds.Count == entry.WordIds.Count) + { + continue; + } + + updateCount++; + if (newIds.Count > 1) + { + entry.WordIds = newIds; + await _mergeGraylistRepo.Update(entry); + } + else + { + await _mergeGraylistRepo.Delete(projectId, entry.Id); + } + } + return updateCount; + } + + /// Get Lists of entries in specified 's graylist. + public async Task>> GetGraylistEntries( + string projectId, int maxLists, string? userId = null) + { + var graylist = await _mergeGraylistRepo.GetAllSets(projectId, userId); + var frontier = await _wordRepo.GetFrontier(projectId); + var wordLists = new List> { Capacity = maxLists }; + foreach (var entry in graylist) + { + if (wordLists.Count == maxLists) + { + break; + } + wordLists.Add(frontier.Where(w => entry.WordIds.Contains(w.Id)).ToList()); + } + return wordLists; + } + /// /// Get Lists of potential duplicate s in specified 's frontier. /// @@ -202,30 +332,32 @@ public async Task>> GetPotentialDuplicates( { var dupFinder = new DuplicateFinder(maxInList, maxLists, 3); - // First pass, only look for words with identical vernacular. var collection = await _wordRepo.GetFrontier(projectId); - var wordLists = await dupFinder.GetIdenticalVernWords( - collection, wordIds => IsInMergeBlacklist(projectId, wordIds, userId)); + async Task isUnavailableSet(List wordIds) => + (await IsInMergeBlacklist(projectId, wordIds, userId)) || + (await IsInMergeGraylist(projectId, wordIds, userId)); + + // First pass, only look for words with identical vernacular. + var wordLists = await dupFinder.GetIdenticalVernWords(collection, isUnavailableSet); // If no such sets found, look for similar words. if (wordLists.Count == 0) { collection = await _wordRepo.GetFrontier(projectId); - wordLists = await dupFinder.GetSimilarWords( - collection, wordIds => IsInMergeBlacklist(projectId, wordIds, userId)); + wordLists = await dupFinder.GetSimilarWords(collection, isUnavailableSet); } return wordLists; } [Serializable] - public class InvalidBlacklistEntryException : Exception + public class InvalidMergeWordSetException : Exception { - public InvalidBlacklistEntryException() { } + public InvalidMergeWordSetException() { } - public InvalidBlacklistEntryException(string message) : base(message) { } + public InvalidMergeWordSetException(string message) : base(message) { } - protected InvalidBlacklistEntryException(SerializationInfo info, StreamingContext context) + protected InvalidMergeWordSetException(SerializationInfo info, StreamingContext context) : base(info, context) { } } } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 711d7a499c..50f3a9c73e 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -197,7 +197,9 @@ public void ConfigureServices(IServiceCollection services) // Merge types services.AddTransient(); + services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); // Password Reset types diff --git a/docs/user_guide/default/images/mergeDefer.png b/docs/user_guide/default/images/mergeDefer.png new file mode 100644 index 0000000000000000000000000000000000000000..992c8e63af8ace6d9a982a3df1dd059ba7af0336 GIT binary patch literal 6365 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 literal 0 HcmV?d00001 diff --git a/docs/user_guide/default/images/mergeSkip.png b/docs/user_guide/default/images/mergeSkip.png deleted file mode 100644 index 3e21737568f86d1e1e7e19511dcd233713518014..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10480 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 AtN;K2 diff --git a/docs/user_guide/docs/en/goals.md b/docs/user_guide/docs/en/goals.md index c9b95838a3..99cc10cfc1 100644 --- a/docs/user_guide/docs/en/goals.md +++ b/docs/user_guide/docs/en/goals.md @@ -105,7 +105,7 @@ Click on the red flag icon to edit the text or remove the flag. ### Finishing a Set There are two buttons at the bottom for wrapping up work on the current set of potential duplicates and moving on to the -next set: "Save & Continue" and "Skip". +next set: "Save & Continue" and "Defer". #### Save & Continue @@ -122,12 +122,12 @@ deleted senses), updating the words in the database. Second, it saves any unmerg If one of the words in an intentionally unmerged set is edited (e.g., in Review Entries), then the set may appear again as potential duplicates. -#### Skip +#### Defer -![Merge Duplicates Skip button](images/mergeSkip.png) +![Merge Duplicates Defer button](images/mergeDefer.png) -The grey "Skip" button resets any changes made to the set of potential duplicates. The same set will be presented as -potential duplicates again the next time Merge Duplicates is opened. +The grey "Defer" button resets any changes made to the set of potential duplicates. The deferred set can be re-visited +via Review Deferred Duplicates. ### Merging with Imported Data diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b483a93cf8..74e2a26202 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -261,14 +261,17 @@ "handleFlags": { "title": "Handle Flags" }, + "reviewDeferredDups": { + "title": "Review Deferred Duplicates" + }, "spellCheckGloss": { - "title": "Spell Check Glossary" + "title": "Spell Check Gloss" }, "validateChars": { "title": "Validate Characters" }, "validateStrWords": { - "title": "Validate Structural Characters" + "title": "Validate Structural Words" }, "reviewEntries": { "title": "Review Entries", @@ -373,7 +376,7 @@ "dups": "Drag duplicate words here", "sense": "Drag new sense here", "saveAndContinue": "Save changes and load a new set of words", - "skip": "Discard changes and load a new set of words", + "defer": "Discard changes and load a new set of words", "list": "Drag this word to the right to start merging it with other words", "noDups": "Nothing to merge.", "delete": "Delete sense", @@ -411,6 +414,7 @@ "cancel": "Cancel", "clearText": "Clear text", "confirm": "Confirm", + "defer": "Defer", "delete": "Delete", "deletePermanently": "Delete permanently?", "done": "Done", @@ -424,7 +428,6 @@ "restore": "Restore", "save": "Save", "saveAndContinue": "Save & Continue", - "skip": "Skip", "undecided": "Undecided", "upload": "Upload" }, diff --git a/src/api/api/merge-api.ts b/src/api/api/merge-api.ts index 7dbbd5547d..c01874811d 100644 --- a/src/api/api/merge-api.ts +++ b/src/api/api/merge-api.ts @@ -107,6 +107,60 @@ export const MergeApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {string} projectId + * @param {number} maxLists + * @param {string} userId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getGraylistEntries: async ( + projectId: string, + maxLists: number, + userId: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("getGraylistEntries", "projectId", projectId); + // verify required parameter 'maxLists' is not null or undefined + assertParamExists("getGraylistEntries", "maxLists", maxLists); + // verify required parameter 'userId' is not null or undefined + assertParamExists("getGraylistEntries", "userId", userId); + const localVarPath = + `/v1/projects/{projectId}/merge/getgraylist/{maxLists}/{userId}` + .replace(`{${"projectId"}}`, encodeURIComponent(String(projectId))) + .replace(`{${"maxLists"}}`, encodeURIComponent(String(maxLists))) + .replace(`{${"userId"}}`, encodeURIComponent(String(userId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} projectId @@ -166,6 +220,63 @@ export const MergeApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {string} projectId + * @param {Array} requestBody + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + graylistAdd: async ( + projectId: string, + requestBody: Array, + options: any = {} + ): Promise => { + // verify required parameter 'projectId' is not null or undefined + assertParamExists("graylistAdd", "projectId", projectId); + // verify required parameter 'requestBody' is not null or undefined + assertParamExists("graylistAdd", "requestBody", requestBody); + const localVarPath = + `/v1/projects/{projectId}/merge/graylist/add`.replace( + `{${"projectId"}}`, + encodeURIComponent(String(projectId)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + requestBody, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} projectId @@ -314,6 +425,39 @@ export const MergeApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} projectId + * @param {number} maxLists + * @param {string} userId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getGraylistEntries( + projectId: string, + maxLists: number, + userId: string, + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise>> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getGraylistEntries( + projectId, + maxLists, + userId, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} projectId @@ -350,6 +494,32 @@ export const MergeApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} projectId + * @param {Array} requestBody + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async graylistAdd( + projectId: string, + requestBody: Array, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise> + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.graylistAdd( + projectId, + requestBody, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {string} projectId @@ -432,6 +602,24 @@ export const MergeApiFactory = function ( .blacklistAdd(projectId, requestBody, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} projectId + * @param {number} maxLists + * @param {string} userId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getGraylistEntries( + projectId: string, + maxLists: number, + userId: string, + options?: any + ): AxiosPromise>> { + return localVarFp + .getGraylistEntries(projectId, maxLists, userId, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} projectId @@ -452,6 +640,22 @@ export const MergeApiFactory = function ( .getPotentialDuplicates(projectId, maxInList, maxLists, userId, options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} projectId + * @param {Array} requestBody + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + graylistAdd( + projectId: string, + requestBody: Array, + options?: any + ): AxiosPromise> { + return localVarFp + .graylistAdd(projectId, requestBody, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {string} projectId @@ -508,6 +712,34 @@ export interface MergeApiBlacklistAddRequest { readonly requestBody: Array; } +/** + * Request parameters for getGraylistEntries operation in MergeApi. + * @export + * @interface MergeApiGetGraylistEntriesRequest + */ +export interface MergeApiGetGraylistEntriesRequest { + /** + * + * @type {string} + * @memberof MergeApiGetGraylistEntries + */ + readonly projectId: string; + + /** + * + * @type {number} + * @memberof MergeApiGetGraylistEntries + */ + readonly maxLists: number; + + /** + * + * @type {string} + * @memberof MergeApiGetGraylistEntries + */ + readonly userId: string; +} + /** * Request parameters for getPotentialDuplicates operation in MergeApi. * @export @@ -543,6 +775,27 @@ export interface MergeApiGetPotentialDuplicatesRequest { readonly userId: string; } +/** + * Request parameters for graylistAdd operation in MergeApi. + * @export + * @interface MergeApiGraylistAddRequest + */ +export interface MergeApiGraylistAddRequest { + /** + * + * @type {string} + * @memberof MergeApiGraylistAdd + */ + readonly projectId: string; + + /** + * + * @type {Array} + * @memberof MergeApiGraylistAdd + */ + readonly requestBody: Array; +} + /** * Request parameters for mergeWords operation in MergeApi. * @export @@ -612,6 +865,27 @@ export class MergeApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {MergeApiGetGraylistEntriesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof MergeApi + */ + public getGraylistEntries( + requestParameters: MergeApiGetGraylistEntriesRequest, + options?: any + ) { + return MergeApiFp(this.configuration) + .getGraylistEntries( + requestParameters.projectId, + requestParameters.maxLists, + requestParameters.userId, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {MergeApiGetPotentialDuplicatesRequest} requestParameters Request parameters. @@ -634,6 +908,26 @@ export class MergeApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {MergeApiGraylistAddRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof MergeApi + */ + public graylistAdd( + requestParameters: MergeApiGraylistAddRequest, + options?: any + ) { + return MergeApiFp(this.configuration) + .graylistAdd( + requestParameters.projectId, + requestParameters.requestBody, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {MergeApiMergeWordsRequest} requestParameters Request parameters. diff --git a/src/backend/index.ts b/src/backend/index.ts index 46e14b3b22..9f5aee5850 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -310,6 +310,14 @@ export async function blacklistAdd(wordIds: string[]): Promise { ); } +/** Adds a list of wordIds to current project's merge graylist */ +export async function graylistAdd(wordIds: string[]): Promise { + await mergeApi.graylistAdd( + { projectId: LocalStorage.getProjectId(), requestBody: wordIds }, + defaultOptions() + ); +} + /** Get list of potential duplicates for merging. */ export async function getDuplicates( maxInList: number, @@ -324,6 +332,17 @@ export async function getDuplicates( return resp.data; } +/** Get list of deferred potential duplicates from graylist for merging. */ +export async function getGraylistEntries(maxLists: number): Promise { + const projectId = LocalStorage.getProjectId(); + const userId = LocalStorage.getUserId(); + const resp = await mergeApi.getGraylistEntries( + { projectId, maxLists, userId }, + defaultOptions() + ); + return resp.data; +} + /* ProjectController.cs */ export async function getAllProjects(): Promise { diff --git a/src/components/GoalTimeline/DefaultState.ts b/src/components/GoalTimeline/DefaultState.ts index 722357c611..aa0ec6565c 100644 --- a/src/components/GoalTimeline/DefaultState.ts +++ b/src/components/GoalTimeline/DefaultState.ts @@ -1,5 +1,7 @@ import { Goal, GoalsState, GoalType } from "types/goals"; +// GoalType.ReviewDeferredDups is also implemented, +// but is conditionally available const implementedTypes: GoalType[] = [ GoalType.CreateCharInv, GoalType.MergeDups, diff --git a/src/components/GoalTimeline/GoalList.tsx b/src/components/GoalTimeline/GoalList.tsx index 9a432b63fe..f5baac7ffe 100644 --- a/src/components/GoalTimeline/GoalList.tsx +++ b/src/components/GoalTimeline/GoalList.tsx @@ -111,7 +111,8 @@ export function makeGoalTile( !goal || (goal.status === GoalStatus.Completed && goal.goalType !== GoalType.CreateCharInv && - goal.goalType !== GoalType.MergeDups) + goal.goalType !== GoalType.MergeDups && + goal.goalType !== GoalType.ReviewDeferredDups) } data-testid="goal-button" > @@ -149,6 +150,7 @@ function getCompletedGoalInfo(goal: Goal): ReactElement { case GoalType.CreateCharInv: return CharInvChangesGoalList(goal.changes as CharInvChanges); case GoalType.MergeDups: + case GoalType.ReviewDeferredDups: return MergesCount(goal.changes as MergesCompleted); default: return ; diff --git a/src/components/GoalTimeline/Redux/GoalActions.ts b/src/components/GoalTimeline/Redux/GoalActions.ts index a59d2a1a88..dce3840252 100644 --- a/src/components/GoalTimeline/Redux/GoalActions.ts +++ b/src/components/GoalTimeline/Redux/GoalActions.ts @@ -2,6 +2,7 @@ import { Action, PayloadAction } from "@reduxjs/toolkit"; import { MergeUndoIds, Word } from "api/models"; import * as Backend from "backend"; +import { getDuplicates, getGraylistEntries } from "backend"; import { getCurrentUser, getProjectId } from "backend/localStorage"; import router from "browserRouter"; import { @@ -15,10 +16,7 @@ import { updateStepFromDataAction, } from "components/GoalTimeline/Redux/GoalReducer"; import { CharacterChange } from "goals/CharacterInventory/CharacterInventoryTypes"; -import { - dispatchMergeStepData, - fetchMergeDupsData, -} from "goals/MergeDuplicates/Redux/MergeDupsActions"; +import { dispatchMergeStepData } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { StoreState } from "types"; import { StoreStateDispatch } from "types/Redux/actions"; import { Goal, GoalStatus, GoalType } from "types/goals"; @@ -171,6 +169,9 @@ export function dispatchStepData(goal: Goal) { case GoalType.MergeDups: dispatch(dispatchMergeStepData(goal)); break; + case GoalType.ReviewDeferredDups: + dispatch(dispatchMergeStepData(goal)); + break; default: break; } @@ -203,11 +204,13 @@ function goalCleanup(goal: Goal): void { } } -// Returns goal data if the goal is MergeDups. +// Returns goal data for some goal types. export async function loadGoalData(goalType: GoalType): Promise { switch (goalType) { case GoalType.MergeDups: - return await fetchMergeDupsData(5, maxNumSteps(goalType)); + return await getDuplicates(5, maxNumSteps(goalType)); + case GoalType.ReviewDeferredDups: + return await getGraylistEntries(maxNumSteps(goalType)); default: return []; } diff --git a/src/components/GoalTimeline/Redux/GoalReducer.ts b/src/components/GoalTimeline/Redux/GoalReducer.ts index 4160c469fd..2632f2f400 100644 --- a/src/components/GoalTimeline/Redux/GoalReducer.ts +++ b/src/components/GoalTimeline/Redux/GoalReducer.ts @@ -18,7 +18,10 @@ const goalSlice = createSlice({ } }, addCompletedMergeToGoalAction: (state, action) => { - if (state.currentGoal.goalType === GoalType.MergeDups) { + if ( + state.currentGoal.goalType === GoalType.MergeDups || + state.currentGoal.goalType === GoalType.ReviewDeferredDups + ) { const changes = { ...state.currentGoal.changes } as MergesCompleted; if (!changes.merges) { changes.merges = []; @@ -60,7 +63,10 @@ const goalSlice = createSlice({ state.currentGoal.status = action.payload; }, updateStepFromDataAction: (state) => { - if (state.currentGoal.goalType === GoalType.MergeDups) { + if ( + state.currentGoal.goalType === GoalType.MergeDups || + state.currentGoal.goalType === GoalType.ReviewDeferredDups + ) { const currentGoalData = state.currentGoal.data as MergeDupsData; state.currentGoal.steps[state.currentGoal.currentStep] = { words: currentGoalData.plannedWords[state.currentGoal.currentStep], diff --git a/src/components/GoalTimeline/index.tsx b/src/components/GoalTimeline/index.tsx index 72a784ba96..93c6074935 100644 --- a/src/components/GoalTimeline/index.tsx +++ b/src/components/GoalTimeline/index.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; -import { getCurrentPermissions } from "backend"; +import { getCurrentPermissions, getGraylistEntries } from "backend"; import GoalList from "components/GoalTimeline/GoalList"; import { asyncAddGoal, @@ -73,6 +73,7 @@ export default function GoalTimeline(): ReactElement { const [availableGoalTypes, setAvailableGoalTypes] = useState([]); const [suggestedGoalTypes, setSuggestedGoalTypes] = useState([]); + const [hasGraylist, setHasGraylist] = useState(false); const [loaded, setLoaded] = useState(false); const [portrait, setPortrait] = useState(true); @@ -85,6 +86,11 @@ export default function GoalTimeline(): ReactElement { dispatch(asyncGetUserEdits()); setLoaded(true); } + const updateHasGraylist = async () => + setHasGraylist( + await getGraylistEntries(1).then((res) => res.length !== 0) + ); + updateHasGraylist(); }, [dispatch, loaded]); useEffect(() => { @@ -93,14 +99,16 @@ export default function GoalTimeline(): ReactElement { const getGoalTypes = useCallback(async (): Promise => { const permissions = await getCurrentPermissions(); - const goalTypes = allGoalTypes.filter((t) => - permissions.includes(requiredPermission(t)) - ); + const goalTypes = ( + hasGraylist + ? allGoalTypes.concat([GoalType.ReviewDeferredDups]) + : allGoalTypes + ).filter((t) => permissions.includes(requiredPermission(t))); setAvailableGoalTypes(goalTypes); setSuggestedGoalTypes( goalTypes.filter((t) => goalTypeSuggestions.includes(t)) ); - }, [allGoalTypes, goalTypeSuggestions]); + }, [allGoalTypes, goalTypeSuggestions, hasGraylist]); useEffect(() => { getGoalTypes(); diff --git a/src/components/GoalTimeline/tests/GoalRedux.test.tsx b/src/components/GoalTimeline/tests/GoalRedux.test.tsx index ef1f373904..5af9ca0bc0 100644 --- a/src/components/GoalTimeline/tests/GoalRedux.test.tsx +++ b/src/components/GoalTimeline/tests/GoalRedux.test.tsx @@ -25,6 +25,7 @@ import { MergeDups, MergeDupsData, MergesCompleted, + ReviewDeferredDups, } from "goals/MergeDuplicates/MergeDupsTypes"; import { goalDataMock } from "goals/MergeDuplicates/Redux/tests/MergeDupsDataMock"; import { setupStore } from "store"; @@ -40,6 +41,7 @@ jest.mock("backend", () => ({ createUserEdit: () => mockCreateUserEdit(), getCurrentPermissions: () => mockGetCurrentPermissions(), getDuplicates: () => mockGetDuplicates(), + getGraylistEntries: (maxLists: number) => mockGetGraylistEntries(maxLists), getUser: (id: string) => mockGetUser(id), getUserEditById: (...args: any[]) => mockGetUserEditById(...args), updateUser: (user: User) => mockUpdateUser(user), @@ -54,6 +56,7 @@ const mockAddStepToGoal = jest.fn(); const mockCreateUserEdit = jest.fn(); const mockGetCurrentPermissions = jest.fn(); const mockGetDuplicates = jest.fn(); +const mockGetGraylistEntries = jest.fn(); const mockGetUser = jest.fn(); const mockGetUserEditById = jest.fn(); const mockNavigate = jest.fn(); @@ -67,6 +70,7 @@ function setMockFunctions() { Permission.MergeAndReviewEntries, ]); mockGetDuplicates.mockResolvedValue(goalDataMock.plannedWords); + mockGetGraylistEntries.mockResolvedValue([]); mockGetUser.mockResolvedValue(mockUser); mockGetUserEditById.mockResolvedValue(mockUserEdit); mockUpdateUser.mockResolvedValue(mockUser); @@ -325,4 +329,29 @@ describe("asyncUpdateGoal", () => { // - backend is called to addGoalToUserEdit expect(mockAddGoalToUserEdit).toBeCalled(); }); + + it("update ReviewDeferredDups goal", async () => { + // setup the test scenario + const store = setupStore(); + await act(async () => { + renderWithProviders(, { store: store }); + }); + // create ReviewDeferredDups goal + const goal = new ReviewDeferredDups(); + await act(async () => { + store.dispatch(asyncAddGoal(goal)); + }); + // dispatch asyncUpdateGoal() + await act(async () => { + store.dispatch(addCompletedMergeToGoal(mockCompletedMerge)); + await store.dispatch(asyncUpdateGoal()); + }); + // verify: + // - current value is now new goal + const changes = store.getState().goalsState.currentGoal + .changes as MergesCompleted; + expect(changes.merges).toEqual([mockCompletedMerge]); + // - backend is called to addGoalToUserEdit + expect(mockAddGoalToUserEdit).toBeCalled(); + }); }); diff --git a/src/components/GoalTimeline/tests/index.test.tsx b/src/components/GoalTimeline/tests/index.test.tsx index 5042eb0327..859e3010a1 100644 --- a/src/components/GoalTimeline/tests/index.test.tsx +++ b/src/components/GoalTimeline/tests/index.test.tsx @@ -13,6 +13,7 @@ import { goalTypeToGoal } from "utilities/goalUtilities"; jest.mock("backend", () => ({ getCurrentPermissions: () => mockGetCurrentPermissions(), + getGraylistEntries: (maxLists: number) => mockGetGraylistEntries(maxLists), })); jest.mock("components/GoalTimeline/Redux/GoalActions", () => ({ asyncAddGoal: (goal: Goal) => mockChooseGoal(goal), @@ -27,6 +28,7 @@ jest.mock("types/hooks", () => { const mockChooseGoal = jest.fn(); const mockGetCurrentPermissions = jest.fn(); +const mockGetGraylistEntries = jest.fn(); const mockProjectId = "mockId"; const mockProjectRoles: { [key: string]: string } = {}; mockProjectRoles[mockProjectId] = "nonempty"; @@ -43,10 +45,11 @@ beforeEach(() => { Permission.CharacterInventory, Permission.MergeAndReviewEntries, ]); + mockGetGraylistEntries.mockResolvedValue([]); }); describe("GoalTimeline", () => { - it("Has the expected number of buttons", async () => { + it("has the expected number of buttons", async () => { await renderTimeline(defaultState.allGoalTypes, allGoals); const buttons = timeLord.root.findAllByType(Button); expect(buttons).toHaveLength( @@ -54,6 +57,17 @@ describe("GoalTimeline", () => { ); }); + it("has one more button if there's a graylist entry", async () => { + mockGetGraylistEntries.mockResolvedValue([ + [{ id: "word1" }, { id: "word2" }], + ]); + await renderTimeline(defaultState.allGoalTypes, allGoals); + const buttons = timeLord.root.findAllByType(Button); + expect(buttons).toHaveLength( + defaultState.allGoalTypes.length + allGoals.length + 1 + ); + }); + it("selects a goal from suggestions", async () => { const goalNumber = 2; await renderTimeline(); diff --git a/src/goals/DefaultGoal/BaseGoalScreen.tsx b/src/goals/DefaultGoal/BaseGoalScreen.tsx index c974114010..06dd04668b 100644 --- a/src/goals/DefaultGoal/BaseGoalScreen.tsx +++ b/src/goals/DefaultGoal/BaseGoalScreen.tsx @@ -6,6 +6,7 @@ import PageNotFound from "components/PageNotFound/component"; import DisplayProgress from "goals/DefaultGoal/DisplayProgress"; import Loading from "goals/DefaultGoal/Loading"; import { clearTree } from "goals/MergeDuplicates/Redux/MergeDupsActions"; +import ReviewDeferredDuplicates from "goals/ReviewDeferredDuplicates"; import { clearReviewEntriesState } from "goals/ReviewEntries/ReviewEntriesComponent/Redux/ReviewEntriesActions"; import { StoreState } from "types"; import { Goal, GoalStatus, GoalType } from "types/goals"; @@ -24,6 +25,8 @@ function displayComponent(goal: Goal): ReactElement { return ; case GoalType.MergeDups: return ; + case GoalType.ReviewDeferredDups: + return ; case GoalType.ReviewEntries: return ; default: diff --git a/src/goals/DefaultGoal/DisplayProgress.tsx b/src/goals/DefaultGoal/DisplayProgress.tsx index 9b0aff3155..e78f4e0e3a 100644 --- a/src/goals/DefaultGoal/DisplayProgress.tsx +++ b/src/goals/DefaultGoal/DisplayProgress.tsx @@ -24,7 +24,9 @@ export default function DisplayProgress() { const percentComplete = (currentStep / numSteps) * 100; const stepTranslateId = - goalType === GoalType.MergeDups ? "goal.progressMerge" : "goal.progress"; + goalType === GoalType.MergeDups || goalType === GoalType.ReviewDeferredDups + ? "goal.progressMerge" + : "goal.progress"; return numSteps > 1 ? ( diff --git a/src/goals/MergeDuplicates/MergeDupsStep/SaveSkipButtons.tsx b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx similarity index 65% rename from src/goals/MergeDuplicates/MergeDupsStep/SaveSkipButtons.tsx rename to src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx index 5f57fb06d1..2417432cec 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/SaveSkipButtons.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons.tsx @@ -1,29 +1,37 @@ -import { Button, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { ReactElement, useState } from "react"; import { useTranslation } from "react-i18next"; import { LoadingButton } from "components/Buttons"; import { asyncAdvanceStep } from "components/GoalTimeline/Redux/GoalActions"; import { + deferMerge, mergeAll, setSidebar, } from "goals/MergeDuplicates/Redux/MergeDupsActions"; import { useAppDispatch } from "types/hooks"; import theme from "types/theme"; -export default function SaveSkipButtons(): ReactElement { +export default function SaveDeferButtons(): ReactElement { const dispatch = useAppDispatch(); + const [isDeferring, setIsDeferring] = useState(false); const [isSaving, setIsSaving] = useState(false); const { t } = useTranslation(); const next = async (): Promise => { - dispatch(setSidebar()); + setIsDeferring(false); setIsSaving(false); await dispatch(asyncAdvanceStep()); }; + const defer = async (): Promise => { + setIsDeferring(true); + dispatch(setSidebar()); + await dispatch(deferMerge()).then(next); + }; + const saveContinue = async (): Promise => { setIsSaving(true); dispatch(setSidebar()); @@ -46,16 +54,19 @@ export default function SaveSkipButtons(): ReactElement { > {t("buttons.saveAndContinue")} - + {t("buttons.defer")} + ); diff --git a/src/goals/MergeDuplicates/MergeDupsStep/index.tsx b/src/goals/MergeDuplicates/MergeDupsStep/index.tsx index 6efdb49907..bd7bc43522 100644 --- a/src/goals/MergeDuplicates/MergeDupsStep/index.tsx +++ b/src/goals/MergeDuplicates/MergeDupsStep/index.tsx @@ -3,7 +3,7 @@ import { ReactElement } from "react"; import { useTranslation } from "react-i18next"; import MergeDragDrop from "goals/MergeDuplicates/MergeDupsStep/MergeDragDrop"; -import SaveSkipButtons from "goals/MergeDuplicates/MergeDupsStep/SaveSkipButtons"; +import SaveDeferButtons from "goals/MergeDuplicates/MergeDupsStep/SaveDeferButtons"; import { StoreState } from "types"; import { useAppSelector } from "types/hooks"; import theme from "types/theme"; @@ -21,7 +21,7 @@ export default function MergeDupsStep(): ReactElement {
- + ) : ( // TODO: create component with button back to goals. diff --git a/src/goals/MergeDuplicates/MergeDupsTypes.ts b/src/goals/MergeDuplicates/MergeDupsTypes.ts index b9279db084..8c5386912e 100644 --- a/src/goals/MergeDuplicates/MergeDupsTypes.ts +++ b/src/goals/MergeDuplicates/MergeDupsTypes.ts @@ -29,3 +29,17 @@ export class MergeDups extends Goal { super(GoalType.MergeDups, GoalName.MergeDups, steps, data); } } + +export class ReviewDeferredDups extends Goal { + constructor( + steps: MergeStepData[] = [], + data: MergeDupsData = { plannedWords: [[]] } + ) { + super( + GoalType.ReviewDeferredDups, + GoalName.ReviewDeferredDups, + steps, + data + ); + } +} diff --git a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts index 775b52f229..31b8b06706 100644 --- a/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts +++ b/src/goals/MergeDuplicates/Redux/MergeDupsActions.ts @@ -21,6 +21,7 @@ import { import { MergeDups, MergeStepData, + ReviewDeferredDups, newMergeWords, } from "goals/MergeDuplicates/MergeDupsTypes"; import { @@ -224,6 +225,13 @@ function getMergeWords( } } +export function deferMerge() { + return async (_: StoreStateDispatch, getState: () => StoreState) => { + const mergeTree = getState().mergeDuplicateGoal; + await backend.graylistAdd(Object.keys(mergeTree.data.words)); + }; +} + export function mergeAll() { return async (dispatch: StoreStateDispatch, getState: () => StoreState) => { const mergeTree = getState().mergeDuplicateGoal; @@ -270,7 +278,7 @@ export function mergeAll() { // Used in MergeDups cases of GoalActions functions -export function dispatchMergeStepData(goal: MergeDups) { +export function dispatchMergeStepData(goal: MergeDups | ReviewDeferredDups) { return (dispatch: StoreStateDispatch) => { const stepData = goal.steps[goal.currentStep] as MergeStepData; if (stepData) { @@ -280,13 +288,6 @@ export function dispatchMergeStepData(goal: MergeDups) { }; } -export async function fetchMergeDupsData( - maxInList: number, - maxLists: number -): Promise { - return await backend.getDuplicates(maxInList, maxLists); -} - /** Modifies the mutable input sense list. */ export function combineIntoFirstSense(senses: MergeTreeSense[]): void { // Set the first sense to be merged as Active/Protected. diff --git a/src/goals/ReviewDeferredDuplicates/index.tsx b/src/goals/ReviewDeferredDuplicates/index.tsx new file mode 100644 index 0000000000..9fcdc5bb85 --- /dev/null +++ b/src/goals/ReviewDeferredDuplicates/index.tsx @@ -0,0 +1,12 @@ +import MergeDupsCompleted from "goals/MergeDuplicates/MergeDupsCompleted"; +import MergeDupsStep from "goals/MergeDuplicates/MergeDupsStep"; + +interface ReviewDeferredDupsProps { + completed: boolean; +} + +export default function ReviewDeferredDuplicates( + props: ReviewDeferredDupsProps +) { + return props.completed ? : ; +} diff --git a/src/goals/SpellCheckGloss/SpellCheckGloss.ts b/src/goals/SpellCheckGloss/SpellCheckGloss.ts index c90d47ed47..8d38defcb6 100644 --- a/src/goals/SpellCheckGloss/SpellCheckGloss.ts +++ b/src/goals/SpellCheckGloss/SpellCheckGloss.ts @@ -2,6 +2,6 @@ import { Goal, GoalName, GoalType } from "types/goals"; export class SpellCheckGloss extends Goal { constructor() { - super(GoalType.SpellcheckGloss, GoalName.SpellcheckGloss); + super(GoalType.SpellCheckGloss, GoalName.SpellCheckGloss); } } diff --git a/src/types/goals.ts b/src/types/goals.ts index 06881956ef..faf7d30818 100644 --- a/src/types/goals.ts +++ b/src/types/goals.ts @@ -37,8 +37,9 @@ export enum GoalType { CreateStrWordInv = 2, HandleFlags = 7, MergeDups = 4, + ReviewDeferredDups = 8, ReviewEntries = 6, - SpellcheckGloss = 5, + SpellCheckGloss = 5, ValidateChars = 1, ValidateStrWords = 3, } @@ -50,8 +51,9 @@ export enum GoalName { CreateStrWordInv = "createStrWordInv", HandleFlags = "handleFlags", MergeDups = "mergeDups", + ReviewDeferredDups = "reviewDeferredDups", ReviewEntries = "reviewEntries", - SpellcheckGloss = "spellcheckGloss", + SpellCheckGloss = "spellCheckGloss", ValidateChars = "validateChars", ValidateStrWords = "validateStrWords", } diff --git a/src/utilities/goalUtilities.ts b/src/utilities/goalUtilities.ts index ed48e96304..21f1be80ca 100644 --- a/src/utilities/goalUtilities.ts +++ b/src/utilities/goalUtilities.ts @@ -2,7 +2,10 @@ import { Edit, Permission } from "api/models"; import { CreateCharInv } from "goals/CharacterInventory/CharacterInventoryTypes"; import { CreateStrWordInv } from "goals/CreateStrWordInv/CreateStrWordInv"; import { HandleFlags } from "goals/HandleFlags/HandleFlags"; -import { MergeDups } from "goals/MergeDuplicates/MergeDupsTypes"; +import { + MergeDups, + ReviewDeferredDups, +} from "goals/MergeDuplicates/MergeDupsTypes"; import { ReviewEntries } from "goals/ReviewEntries/ReviewEntries"; import { SpellCheckGloss } from "goals/SpellCheckGloss/SpellCheckGloss"; import { ValidateChars } from "goals/ValidateChars/ValidateChars"; @@ -13,6 +16,8 @@ export function maxNumSteps(type: GoalType): number { switch (type) { case GoalType.MergeDups: return 12; + case GoalType.ReviewDeferredDups: + return 99; default: return 1; } @@ -22,6 +27,7 @@ export function maxNumSteps(type: GoalType): number { export function requiredPermission(type: GoalType): Permission { switch (type) { case GoalType.MergeDups: + case GoalType.ReviewDeferredDups: case GoalType.ReviewEntries: return Permission.MergeAndReviewEntries; case GoalType.CreateCharInv: @@ -41,9 +47,11 @@ export function goalTypeToGoal(type: GoalType): Goal { return new HandleFlags(); case GoalType.MergeDups: return new MergeDups(); + case GoalType.ReviewDeferredDups: + return new ReviewDeferredDups(); case GoalType.ReviewEntries: return new ReviewEntries(); - case GoalType.SpellcheckGloss: + case GoalType.SpellCheckGloss: return new SpellCheckGloss(); case GoalType.ValidateChars: return new ValidateChars();