diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 94fedc809f..9e9a2037a0 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -19,6 +19,7 @@ using SIL.LCModel.Core.WritingSystems; using SIL.LCModel.DomainServices; using SIL.LCModel.Infrastructure; +using CollectionExtensions = SIL.Extensions.CollectionExtensions; namespace FwDataMiniLcmBridge.Api; @@ -94,7 +95,7 @@ public Task GetWritingSystems() { Vernacular = WritingSystemContainer.CurrentVernacularWritingSystems.Select((definition, index) => FromLcmWritingSystem(definition, index, WritingSystemType.Vernacular)).ToArray(), - Analysis = Cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems.Select((definition, index) => + Analysis = WritingSystemContainer.CurrentAnalysisWritingSystems.Select((definition, index) => FromLcmWritingSystem(definition, index, WritingSystemType.Analysis)).ToArray() }; CompleteExemplars(writingSystems); @@ -117,7 +118,7 @@ private WritingSystem FromLcmWritingSystem(CoreWritingSystemDefinition ws, int i }; } - public async Task GetWritingSystem(WritingSystemId id, WritingSystemType type) + public async Task GetWritingSystem(WritingSystemId id, WritingSystemType type) { var writingSystems = await GetWritingSystems(); return type switch @@ -125,7 +126,7 @@ public async Task GetWritingSystem(WritingSystemId id, WritingSys WritingSystemType.Vernacular => writingSystems.Vernacular.FirstOrDefault(ws => ws.WsId == id), WritingSystemType.Analysis => writingSystems.Analysis.FirstOrDefault(ws => ws.WsId == id), _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) - } ?? throw new NullReferenceException($"unable to find writing system with id {id}"); + }; } internal void CompleteExemplars(WritingSystems writingSystems) @@ -149,7 +150,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) } } - public async Task CreateWritingSystem(WritingSystem writingSystem) + public async Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) { var type = writingSystem.Type; var exitingWs = type == WritingSystemType.Analysis ? Cache.ServiceLocator.WritingSystems.AnalysisWritingSystems : Cache.ServiceLocator.WritingSystems.VernacularWritingSystems; @@ -158,10 +159,9 @@ public async Task CreateWritingSystem(WritingSystem writingSystem throw new DuplicateObjectException($"Writing system {writingSystem.WsId.Code} already exists"); } CoreWritingSystemDefinition? ws = null; - UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Writing System", + await Cache.DoUsingNewOrCurrentUOW("Create Writing System", "Remove writing system", - Cache.ServiceLocator.ActionHandler, - () => + async () => { Cache.ServiceLocator.WritingSystemManager.GetOrSet(writingSystem.WsId.Code, out ws); ws.Abbreviation = writingSystem.Abbreviation; @@ -176,6 +176,9 @@ public async Task CreateWritingSystem(WritingSystem writingSystem default: throw new ArgumentOutOfRangeException(nameof(type), type, null); } + + if (between is not null && (between.Previous is not null || between.Next is not null)) + await MoveWritingSystem(writingSystem.WsId, type, between); }); if (ws is null) throw new InvalidOperationException("Writing system not found"); var index = type switch @@ -205,7 +208,7 @@ await Cache.DoUsingNewOrCurrentUOW("Update WritingSystem", update.Apply(updateProxy); return ValueTask.CompletedTask; }); - return await GetWritingSystem(id, type); + return await GetWritingSystem(id, type) ?? throw new NullReferenceException($"unable to find writing system with id {id}"); } public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api = null) @@ -219,6 +222,61 @@ await Cache.DoUsingNewOrCurrentUOW("Update WritingSystem", return await GetWritingSystem(after.WsId, after.Type) ?? throw new NullReferenceException($"unable to find {after.Type} writing system with id {after.WsId}"); } + public async Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + { + var wsToUpdate = GetLexWritingSystem(id, type); + if (wsToUpdate is null) throw new NullReferenceException($"unable to find writing system with id {id}"); + var previousWs = between.Previous is null ? null : GetLexWritingSystem(between.Previous.Value, type); + var nextWs = between.Next is null ? null : GetLexWritingSystem(between.Next.Value, type); + if (nextWs is null && previousWs is null) throw new NullReferenceException($"unable to find writing system with id {between.Previous} or {between.Next}"); + await Cache.DoUsingNewOrCurrentUOW("Move WritingSystem", + "Revert Move WritingSystem", + () => + { + var exitingWs = type == WritingSystemType.Analysis + ? WritingSystemContainer.AnalysisWritingSystems + : WritingSystemContainer.VernacularWritingSystems; + var currentExistingWs = type == WritingSystemType.Analysis + ? WritingSystemContainer.CurrentAnalysisWritingSystems + : WritingSystemContainer.CurrentVernacularWritingSystems; + MoveWs(wsToUpdate, previousWs, nextWs, exitingWs); + MoveWs(wsToUpdate, previousWs, nextWs, currentExistingWs); + + void MoveWs(CoreWritingSystemDefinition ws, + CoreWritingSystemDefinition? previous, + CoreWritingSystemDefinition? next, + ICollection list) + { + var index = -1; + if (previous is not null) + { + index = CollectionExtensions.IndexOf(list, previous); + if (index >= 0) index++; + } + + if (index < 0 && next is not null) + { + index = CollectionExtensions.IndexOf(list, next); + } + + if (index < 0) + throw new InvalidOperationException("unable to find writing system with id " + between.Previous + " or " + between.Next); + + LcmHelpers.AddOrMoveInList(list, index, ws); + } + + return ValueTask.CompletedTask; + }); + } + + private CoreWritingSystemDefinition? GetLexWritingSystem(WritingSystemId id, WritingSystemType type) + { + var exitingWs = type == WritingSystemType.Analysis + ? WritingSystemContainer.AnalysisWritingSystems + : WritingSystemContainer.VernacularWritingSystems; + return exitingWs.FirstOrDefault(ws => ws.Id == id); + } + public IAsyncEnumerable GetPartsOfSpeech() { return PartOfSpeechRepository diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 3812af59e3..4b660254cb 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -1,9 +1,11 @@ using System.Globalization; using MiniLcm.Culture; using MiniLcm.Models; +using SIL.Extensions; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; +using SIL.LCModel.Core.WritingSystems; namespace FwDataMiniLcmBridge.Api; @@ -199,4 +201,41 @@ internal static void SetString(this ITsMultiString multiString, FwDataMiniLcmApi multiString.set_String(writingSystemHandle, tsString); } } + + //mostly a copy of method in SIL.FieldWorks.FwCoreDlgs.FwWritingSystemSetupModel + internal static void AddOrMoveInList( + ICollection allWritingSystems, + int desiredIndex, + CoreWritingSystemDefinition workingWs + ) + { + if (desiredIndex < 0) throw new ArgumentOutOfRangeException(nameof(desiredIndex), desiredIndex, "desiredIndex must be >= 0"); + + // copy original contents into a list + var updatedList = new List(allWritingSystems); + var ws = updatedList.Find(listItem => + { + if (ReferenceEquals(listItem, workingWs)) return true; + var workingTag = string.IsNullOrEmpty(workingWs.Id) ? workingWs.LanguageTag : workingWs.Id; + var listItemTag = string.IsNullOrEmpty(listItem.Id) ? listItem.LanguageTag : listItem.Id; + return string.Equals(listItemTag, workingTag); + }); + + if (ws != null) + { + updatedList.Remove(ws); + } + + if (desiredIndex > updatedList.Count) + { + updatedList.Add(workingWs); + } + else + { + updatedList.Insert(desiredIndex, workingWs); + } + + allWritingSystems.Clear(); + allWritingSystems.AddRange(updatedList); + } } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs index d280802f6f..e53c7aa158 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs @@ -203,9 +203,9 @@ Task IMiniLcmWriteApi.CreatePublication(Publication publication) ResumableTests.MaybeThrowRandom(random, 0.2); return _api.CreatePublication(publication); } - Task IMiniLcmWriteApi.CreateWritingSystem(WritingSystem writingSystems) + Task IMiniLcmWriteApi.CreateWritingSystem(WritingSystem writingSystems, BetweenPosition? between) { ResumableTests.MaybeThrowRandom(random, 0.2); - return _api.CreateWritingSystem(writingSystems); + return _api.CreateWritingSystem(writingSystems, between); } } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/WritingSystemSyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/WritingSystemSyncTests.cs new file mode 100644 index 0000000000..9d36afe5e1 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/WritingSystemSyncTests.cs @@ -0,0 +1,79 @@ +using FwLiteProjectSync.Tests.Fixtures; +using MiniLcm.Models; + +namespace FwLiteProjectSync.Tests; + +public class WritingSystemSyncTests : IClassFixture, IAsyncLifetime +{ + + private readonly SyncFixture _fixture; + private readonly CrdtFwdataProjectSyncService _syncService; + + public WritingSystemSyncTests(SyncFixture fixture) + { + _fixture = fixture; + _syncService = _fixture.SyncService; + } + + public async Task InitializeAsync() + { + await _fixture.FwDataApi.CreateEntry(new Entry() + { + Id = Guid.NewGuid(), + LexemeForm = { { "en", "Pineapple" } }, + }); + await _fixture.FwDataApi.CreateWritingSystem(new WritingSystem() + { + Id = Guid.NewGuid(), + Type = WritingSystemType.Vernacular, + WsId = new WritingSystemId("fr"), + Name = "French", + Abbreviation = "fr", + Font = "Arial" + }); + await _syncService.Sync(_fixture.CrdtApi, _fixture.FwDataApi); + } + + public async Task DisposeAsync() + { + await foreach (var entry in _fixture.FwDataApi.GetAllEntries()) + { + await _fixture.FwDataApi.DeleteEntry(entry.Id); + } + + foreach (var entry in await _fixture.CrdtApi.GetAllEntries().ToArrayAsync()) + { + await _fixture.CrdtApi.DeleteEntry(entry.Id); + } + } + + [Fact] + public async Task SyncWs_UpdatesOrder() + { + var en = await _fixture.FwDataApi.GetWritingSystem("en", WritingSystemType.Vernacular); + en.Should().NotBeNull(); + en.Order.Should().Be(0); + var fr = await _fixture.FwDataApi.GetWritingSystem("fr", WritingSystemType.Vernacular); + fr.Should().NotBeNull(); + fr.Order.Should().Be(1); + await _fixture.FwDataApi.MoveWritingSystem("fr", WritingSystemType.Vernacular, new(null, "en")); + fr = await _fixture.FwDataApi.GetWritingSystem("fr", WritingSystemType.Vernacular); + fr.Should().NotBeNull(); + fr.Order.Should().Be(0); + var crdtFr = await _fixture.CrdtApi.GetWritingSystem("fr", WritingSystemType.Vernacular); + crdtFr.Should().NotBeNull(); + crdtFr.Order.Should().Be(1); + + await _syncService.Sync(_fixture.CrdtApi, _fixture.FwDataApi); + + fr = await _fixture.FwDataApi.GetWritingSystem("fr", WritingSystemType.Vernacular); + fr.Should().NotBeNull(); + fr.Order.Should().Be(0); + + var crdtEn = await _fixture.CrdtApi.GetWritingSystem("en", WritingSystemType.Vernacular); + crdtEn.Should().NotBeNull(); + var actualFr = await _fixture.CrdtApi.GetWritingSystem("fr", WritingSystemType.Vernacular); + actualFr.Should().NotBeNull(); + actualFr.Order.Should().BeLessThan(crdtEn.Order); + } +} diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index 87c5d28549..3cee26fbc1 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -18,9 +18,10 @@ public void Dispose() public record DryRunRecord(string Method, string Description); - public Task CreateWritingSystem(WritingSystem writingSystem) + public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateWritingSystem), $"Create writing system {writingSystem.Type}")); + DryRunRecords.Add(new DryRunRecord(nameof(CreateWritingSystem), + $"Create writing system {writingSystem.Type} between {position?.Previous} and {position?.Next}")); return Task.FromResult(writingSystem); } @@ -45,6 +46,12 @@ public Task UpdateWritingSystem(WritingSystem before, WritingSyst return Task.FromResult(after); } + public async Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + { + DryRunRecords.Add(new DryRunRecord(nameof(MoveWritingSystem), $"Move writing system {id} between {between.Previous} and {between.Next}")); + await Task.CompletedTask; + } + public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); diff --git a/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs b/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs index bf63243712..00bf736ab1 100644 --- a/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs +++ b/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using MiniLcm; using MiniLcm.Models; +using MiniLcm.SyncHelpers; namespace FwLiteProjectSync.Import; @@ -65,9 +66,9 @@ async Task IMiniLcmWriteApi.CreatePublication(Publication publicati { return await HasCreated(publication, _api.GetPublications(), () => _api.CreatePublication(publication)); } - async Task IMiniLcmWriteApi.CreateWritingSystem(WritingSystem writingSystem) + async Task IMiniLcmWriteApi.CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) { - return await HasCreated(writingSystem, AsyncWs(), () => _api.CreateWritingSystem(writingSystem), ws => ws.Type + ws.WsId.Code); + return await HasCreated(writingSystem, AsyncWs(), () => _api.CreateWritingSystem(writingSystem, between), ws => ws.Type + ws.WsId.Code); } private async IAsyncEnumerable AsyncWs() diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/RegressionDeserializationData.json b/backend/FwLite/LcmCrdt.Tests/Changes/RegressionDeserializationData.json index cc4acedfff..7d74dfec83 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/RegressionDeserializationData.json +++ b/backend/FwLite/LcmCrdt.Tests/Changes/RegressionDeserializationData.json @@ -277,6 +277,11 @@ }, "EntityId": "e03826fb-e6d2-6e3c-1f43-9fc6a0f6c4c6" }, + { + "$type": "SetOrderChange:WritingSystem", + "Order": 0.4870418422144669, + "EntityId": "9d3e9006-c4b4-4316-6628-faeede75af40" + }, { "$type": "SetOrderChange:Sense", "Order": 0.4870418422144669, diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs index 83ce47e7ed..04e1543132 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs @@ -199,6 +199,9 @@ private static IEnumerable GetAllChanges() var setComplexFormComponentOrderChange = new LcmCrdt.Changes.SetOrderChange(complexFormComponent.Id, 10); yield return new ChangeWithDependencies(setComplexFormComponentOrderChange, [createComplexFormComponentChange]); + var setWritingSystemOrderChange = new LcmCrdt.Changes.SetOrderChange(writingSystem.Id, 10); + yield return new ChangeWithDependencies(setWritingSystemOrderChange, [createWritingSystemChange]); + var publication = new Publication { Id = Guid.NewGuid(), Name = { { "en", "Main" } } }; var createPublicationChange = new CreatePublicationChange(publication.Id, publication.Name); yield return new ChangeWithDependencies(createPublicationChange); diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index 388fb1480e..2b712953af 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -171,6 +171,10 @@ { DerivedType: SetOrderChange, TypeDiscriminator: SetOrderChange:ComplexFormComponent + }, + { + DerivedType: SetOrderChange, + TypeDiscriminator: SetOrderChange:WritingSystem } ], IgnoreUnrecognizedTypeDiscriminators: false, diff --git a/backend/FwLite/LcmCrdt/Changes/SetOrderChange.cs b/backend/FwLite/LcmCrdt/Changes/SetOrderChange.cs index fa97d50a02..97b6a1008a 100644 --- a/backend/FwLite/LcmCrdt/Changes/SetOrderChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/SetOrderChange.cs @@ -5,7 +5,7 @@ namespace LcmCrdt.Changes; public class SetOrderChange(Guid entityId, double order) : EditChange(entityId), IPolyType - where T : class, IOrderable + where T : class, IOrderableNoId { public static string TypeName => $"{nameof(SetOrderChange)}:" + typeof(T).Name; diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index e5b18cd204..c633eb5d71 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -78,15 +78,17 @@ public async Task GetWritingSystems() }; } - public async Task CreateWritingSystem(WritingSystem writingSystem) + public async Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) { await using var repo = await repoFactory.CreateRepoAsync(); var entityId = Guid.NewGuid(); - var exists = await repo.WritingSystems.AnyAsync(ws => ws.WsId == writingSystem.WsId && ws.Type == writingSystem.Type); + var wsType = writingSystem.Type; + var exists = await repo.WritingSystems.AnyAsync(ws => ws.WsId == writingSystem.WsId && ws.Type == wsType); if (exists) throw new DuplicateObjectException($"Writing system {writingSystem.WsId.Code} already exists"); - var wsCount = await repo.WritingSystems.CountAsync(ws => ws.Type == writingSystem.Type); - await AddChange(new CreateWritingSystemChange(writingSystem, entityId, wsCount)); - return await repo.GetWritingSystem(writingSystem.WsId, writingSystem.Type) ?? throw new NullReferenceException(); + var betweenIds = between is null ? null : await between.MapAsync(async wsId => wsId is null ? null : (await repo.GetWritingSystem(wsId.Value, wsType))?.Id); + var order = await OrderPicker.PickOrder(repo.WritingSystems.Where(ws => ws.Type == wsType), betweenIds); + await AddChange(new CreateWritingSystemChange(writingSystem, entityId, order)); + return await repo.GetWritingSystem(writingSystem.WsId, wsType) ?? throw new NullReferenceException(); } public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) @@ -105,7 +107,17 @@ public async Task UpdateWritingSystem(WritingSystem before, Writi return await GetWritingSystem(after.WsId, after.Type) ?? throw new NullReferenceException("unable to find writing system with id " + after.WsId); } - private async ValueTask GetWritingSystem(WritingSystemId id, WritingSystemType type) + public async Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var ws = await repo.GetWritingSystem(id, type); + if (ws is null) throw new NullReferenceException($"unable to find writing system with id {id}"); + var betweenIds = await between.MapAsync(async wsId => wsId is null ? null : (await repo.GetWritingSystem(wsId.Value, type))?.Id); + var order = await OrderPicker.PickOrder(repo.WritingSystems.Where(s => s.Type == type), betweenIds); + await AddChange(new Changes.SetOrderChange(ws.Id, order)); + } + + public async Task GetWritingSystem(WritingSystemId id, WritingSystemType type) { await using var repo = await repoFactory.CreateRepoAsync(); return await repo.GetWritingSystem(id, type); diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 7b8535f8af..2a7c75af85 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -259,6 +259,7 @@ public static void ConfigureCrdt(CrdtConfig config) .Add>() .Add>() .Add>() + .Add>() // When adding anything other than a Delete or JsonPatch change, // you must add an instance of it to UseChangesTests.GetAllChanges() ; diff --git a/backend/FwLite/LcmCrdt/OrderPicker.cs b/backend/FwLite/LcmCrdt/OrderPicker.cs index e044ee110a..14eb7746e7 100644 --- a/backend/FwLite/LcmCrdt/OrderPicker.cs +++ b/backend/FwLite/LcmCrdt/OrderPicker.cs @@ -5,7 +5,8 @@ namespace LcmCrdt; public static class OrderPicker { - public static async Task PickOrder(IQueryable siblings, BetweenPosition? between = null) where T : class, IOrderable + public static async Task PickOrder(IQueryable siblings, BetweenPosition? between = null) + where T : class, IOrderableNoId, IObjectWithId//this is weird, but WritingSystems should not be IOrderable, because that won't work with FW data, but they have Ids when working with CRDTs { // a common case that we can optimize by not querying whole objects if (between is null or { Previous: null, Next: null }) diff --git a/backend/FwLite/MiniLcm.Tests/WritingSystemTestsBase.cs b/backend/FwLite/MiniLcm.Tests/WritingSystemTestsBase.cs index f7f359aae8..eef8c431ba 100644 --- a/backend/FwLite/MiniLcm.Tests/WritingSystemTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/WritingSystemTestsBase.cs @@ -1,5 +1,5 @@ using MiniLcm.Exceptions; -using MiniLcm.Models; +using MiniLcm.SyncHelpers; namespace MiniLcm.Tests; @@ -72,4 +72,77 @@ public async Task UpdateExistingWritingSystem_Works() var updatedWritingSystem = await Api.UpdateWritingSystem(original, writingSystem); updatedWritingSystem.Abbreviation.Should().Be("New Abbreviation"); } + + [Fact] + public async Task MoveWritingSystem_Works() + { + var ws1 = await Api.CreateWritingSystem(new() + { + Id = Guid.NewGuid(), + WsId = "es", + Type = WritingSystemType.Vernacular, + Name = "Spanish", + Abbreviation = "Es", + Font = "Arial" + }); + var ws2 = await Api.CreateWritingSystem(new() + { + Id = Guid.NewGuid(), + WsId = "fr", + Type = WritingSystemType.Vernacular, + Name = "French", + Abbreviation = "Fr", + Font = "Arial" + }); + ws2.Order.Should().BeGreaterThan(ws1.Order); + + //act + await Api.MoveWritingSystem(ws2.WsId, WritingSystemType.Vernacular, new(null, ws1.WsId)); + + //assert + ws1 = await Api.GetWritingSystem(ws1.WsId, WritingSystemType.Vernacular); + ws1.Should().NotBeNull(); + ws2 = await Api.GetWritingSystem(ws2.WsId, WritingSystemType.Vernacular); + ws2.Should().NotBeNull(); + ws2.Order.Should().BeLessThan(ws1.Order); + + var writingSystems = await Api.GetWritingSystems(); + var en = writingSystems.Vernacular.Single(ws => ws.WsId.Code == "en"); + writingSystems.Vernacular.Should().BeEquivalentTo([en, ws2, ws1], + // we care about the order of return, not the internal Order property + options => options.WithStrictOrdering().Excluding(ws => ws.Order)); + } + + [Fact] + public async Task InsertWritingSystem_Works() + { + var writingSystems = await Api.GetWritingSystems(); + var en = writingSystems.Vernacular.Single(ws => ws.WsId.Code == "en"); + + //act + var ws1 = await Api.CreateWritingSystem(new() + { + Id = Guid.NewGuid(), + WsId = "es", + Type = WritingSystemType.Vernacular, + Name = "Spanish", + Abbreviation = "Es", + Font = "Arial" + }, new BetweenPosition(null, en.WsId)); + var ws2 = await Api.CreateWritingSystem(new() + { + Id = Guid.NewGuid(), + WsId = "fr", + Type = WritingSystemType.Vernacular, + Name = "French", + Abbreviation = "Fr", + Font = "Arial" + }, new BetweenPosition(ws1.WsId, en.WsId)); + + // assert + writingSystems = await Api.GetWritingSystems(); + writingSystems.Vernacular.Should().BeEquivalentTo([ws1, ws2, en], + // we care about the order of return, not the internal Order property + options => options.WithStrictOrdering().Excluding(ws => ws.Order)); + } } diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index a6c4865271..3607a295db 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -9,6 +9,7 @@ namespace MiniLcm; public interface IMiniLcmReadApi { Task GetWritingSystems(); + Task GetWritingSystem(WritingSystemId id, WritingSystemType type); IAsyncEnumerable GetPartsOfSpeech(); IAsyncEnumerable GetPublications(); IAsyncEnumerable GetSemanticDomains(); diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index dd33a7afbb..0002ab05fd 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -8,13 +8,14 @@ namespace MiniLcm; public interface IMiniLcmWriteApi { - Task CreateWritingSystem(WritingSystem writingSystem); + Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null); Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update); Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api = null); // Note there's no Task DeleteWritingSystem(Guid id) because deleting writing systems needs careful consideration, as it can cause a massive cascade of data deletion + Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between); #region PartOfSpeech Task CreatePartOfSpeech(PartOfSpeech partOfSpeech); diff --git a/backend/FwLite/MiniLcm/Models/IOrderable.cs b/backend/FwLite/MiniLcm/Models/IOrderable.cs index 994e2e31ce..2d22155605 100644 --- a/backend/FwLite/MiniLcm/Models/IOrderable.cs +++ b/backend/FwLite/MiniLcm/Models/IOrderable.cs @@ -1,7 +1,11 @@ namespace MiniLcm.Models; -public interface IOrderable +public interface IOrderableNoId { - Guid Id { get; } double Order { get; set; } } + +public interface IOrderable: IOrderableNoId +{ + Guid Id { get; } +} diff --git a/backend/FwLite/MiniLcm/Models/WritingSystem.cs b/backend/FwLite/MiniLcm/Models/WritingSystem.cs index 68c163855f..c3c4971f6f 100644 --- a/backend/FwLite/MiniLcm/Models/WritingSystem.cs +++ b/backend/FwLite/MiniLcm/Models/WritingSystem.cs @@ -1,9 +1,13 @@ using System.Text.Json.Serialization; +using MiniLcm.Attributes; namespace MiniLcm.Models; -public record WritingSystem: IObjectWithId +public record WritingSystem: IObjectWithId, IOrderableNoId { + /// + /// this ID is always empty when working with FW data, it is only used when working with CRDTs + /// public required Guid Id { get; set; } public virtual required WritingSystemId WsId { get; set; } public bool IsAudio => WsId.IsAudio; @@ -18,6 +22,7 @@ public record WritingSystem: IObjectWithId public static string[] LatinExemplars => Enumerable.Range('A', 'Z' - 'A' + 1).Select(c => ((char)c).ToString()).ToArray(); + [MiniLcmInternal] public double Order { get; set; } public Guid[] GetReferences() diff --git a/backend/FwLite/MiniLcm/Models/WritingSystemId.cs b/backend/FwLite/MiniLcm/Models/WritingSystemId.cs index 607ef97ae4..79b8e65a2c 100644 --- a/backend/FwLite/MiniLcm/Models/WritingSystemId.cs +++ b/backend/FwLite/MiniLcm/Models/WritingSystemId.cs @@ -47,6 +47,7 @@ public static WritingSystemId FromUnknown(object? obj) public WritingSystemId(string code) { + ArgumentException.ThrowIfNullOrEmpty(code); //__key is used by the LfClassicMiniLcmApi to smuggle non guid ids with possibilitie lists if (code == "default" || code == "__key") { diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index aecad8ea03..7d8376ae71 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -245,5 +245,13 @@ public record PositionDiff(int Index, PositionDiffKind Kind) public int SortIndex => Kind == PositionDiffKind.Remove ? -Index - 1 : Index; } -public record BetweenPosition(T? Previous, T? Next); +public record BetweenPosition(T? Previous, T? Next) +{ + public async Task MapAsync(Func> map) + { + return new BetweenPosition( + Previous is null ? null : await map(Previous), + Next is null ? null : await map(Next)); + } +} public record BetweenPosition(Guid? Previous, Guid? Next) : BetweenPosition(Previous, Next); diff --git a/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs index 114bd2ea26..11cf303543 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/WritingSystemSync.cs @@ -1,4 +1,5 @@ using MiniLcm.Models; +using SIL.Extensions; using SystemTextJsonPatch; namespace MiniLcm.SyncHelpers; @@ -16,10 +17,8 @@ public static async Task Sync(WritingSystem[] beforeWritingSystems, WritingSystem[] afterWritingSystems, IMiniLcmApi api) { - return await DiffCollection.Diff( - beforeWritingSystems, - afterWritingSystems, - new WritingSystemsDiffApi(api)); + var writingSystemsDiffApi = new WritingSystemsDiffApi(api); + return await writingSystemsDiffApi.Diff(beforeWritingSystems, afterWritingSystems); } public static async Task Sync(WritingSystem beforeWs, WritingSystem afterWs, IMiniLcmApi api) @@ -51,29 +50,85 @@ public static async Task Sync(WritingSystem beforeWs, WritingSystem afterWs return new UpdateObjectInput(patchDocument); } - private class WritingSystemsDiffApi(IMiniLcmApi api) : CollectionDiffApi + private class WritingSystemsDiffApi(IMiniLcmApi api) : IOrderableCollectionDiffApi { - public override (WritingSystemId, WritingSystemType) GetId(WritingSystem value) + private Dictionary Mapping { get; } = new(); + public async Task Diff(WritingSystem[] beforeWritingSystems, WritingSystem[] afterWritingSystems) { - return (value.WsId, value.Type); + return await DiffCollection.DiffOrderable( + //diff collection must work with a Guid, and the id's must match between the two lists + //so we just generate the guids on the fly and make sure they're the same for the same wsId + beforeWritingSystems.Select(ws => new OrderableWs(ws, GetOrderableId(ws.WsId))).OrderBy(o => o.Order).ToList(), + afterWritingSystems.Select(ws => new OrderableWs(ws, GetOrderableId(ws.WsId))).OrderBy(o => o.Order).ToList(), + this + ); } - public override async Task Add(WritingSystem currentWs) + private Guid GetOrderableId(WritingSystemId wsId) { - await api.CreateWritingSystem(currentWs); + foreach (var kvp in Mapping) + { + if (kvp.Value == wsId) + { + return kvp.Key; + } + } + var newId = Guid.NewGuid(); + Mapping[newId] = wsId; + return newId; + } + + private class OrderableWs : IOrderable + { + public WritingSystem Ws { get; } + //can't use Ws Guid because it is not set for FwData + public Guid Id { get; } + + public double Order + { + get => Ws.Order; + set => Ws.Order = value; + } + + public OrderableWs(WritingSystem ws, Guid id) + { + this.Ws = ws; + this.Id = id; + } + } + + async Task IOrderableCollectionDiffApi.Add(OrderableWs value, BetweenPosition between) + { + var betweenWsId = new BetweenPosition( + //we can't use `null` and must use new WritingSystemId?() to set a nullable value to null + between.Previous is null ? new WritingSystemId?() : Mapping[between.Previous.Value], + between.Next is null ? new WritingSystemId?() : Mapping[between.Next.Value] + ); + await api.CreateWritingSystem(value.Ws, betweenWsId); return 1; } - public override Task Remove(WritingSystem beforeWs) + Task IOrderableCollectionDiffApi.Remove(OrderableWs value) { // await api.DeleteWritingSystem(beforeWs.Id); // Deleting writing systems is dangerous as it causes cascading data deletion. Needs careful thought. // TODO: should we throw an exception? return Task.FromResult(0); } - public override Task Replace(WritingSystem beforeWs, WritingSystem afterWs) + async Task IOrderableCollectionDiffApi.Move(OrderableWs value, BetweenPosition between) + { + var betweenWsId = new BetweenPosition( + //we can't use `null` and must use new WritingSystemId?() to set a nullable value to null + between.Previous is null ? new WritingSystemId?() : Mapping[between.Previous.Value], + between.Next is null ? new WritingSystemId?() : Mapping[between.Next.Value] + ); + await api.MoveWritingSystem(value.Ws.WsId, value.Ws.Type, betweenWsId); + return 1; + } + + Task IOrderableCollectionDiffApi.Replace(OrderableWs before, OrderableWs after) { - return Sync(beforeWs, afterWs, api); + return Sync(before.Ws, after.Ws, api); } } } diff --git a/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs b/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs index b360fdc73a..077873b5d1 100644 --- a/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs +++ b/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs @@ -21,10 +21,10 @@ public partial class MiniLcmApiValidationWrapper( // ********** Overrides go here ********** - public async Task CreateWritingSystem(WritingSystem writingSystem) + public async Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) { await validators.ValidateAndThrow(writingSystem); - return await _api.CreateWritingSystem(writingSystem); + return await _api.CreateWritingSystem(writingSystem, between); } public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) diff --git a/backend/LfClassicData/LfClassicMiniLcmApi.cs b/backend/LfClassicData/LfClassicMiniLcmApi.cs index 23b59e5191..bfe215438e 100644 --- a/backend/LfClassicData/LfClassicMiniLcmApi.cs +++ b/backend/LfClassicData/LfClassicMiniLcmApi.cs @@ -64,6 +64,17 @@ public async Task GetWritingSystems() }; } + public async Task GetWritingSystem(WritingSystemId id, WritingSystemType type) + { + var ws = await GetWritingSystems(); + return type switch + { + WritingSystemType.Vernacular => ws.Vernacular.FirstOrDefault(w => w.WsId == id), + WritingSystemType.Analysis => ws.Analysis.FirstOrDefault(w => w.WsId == id), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + } + public async Task PickDefaultVernacularWritingSystem() { var cacheKey = $"LfClassic|DefaultVernacular|{projectCode}"; diff --git a/frontend/viewer/src/lib/demo-entry-data.ts b/frontend/viewer/src/lib/demo-entry-data.ts index 08ce32835b..1cf2c89efa 100644 --- a/frontend/viewer/src/lib/demo-entry-data.ts +++ b/frontend/viewer/src/lib/demo-entry-data.ts @@ -40,7 +40,6 @@ export const writingSystems: IWritingSystems = { 'font': '???', 'exemplars': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'], 'type': WritingSystemType.Analysis, - 'order': 0, isAudio: false, }, { @@ -51,7 +50,6 @@ export const writingSystems: IWritingSystems = { 'font': '???', 'exemplars': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'], 'type': WritingSystemType.Analysis, - 'order': 1, isAudio: false, } ], @@ -64,7 +62,6 @@ export const writingSystems: IWritingSystems = { 'font': '???', 'exemplars': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'], 'type': WritingSystemType.Vernacular, - 'order': 0, isAudio: false, }, { @@ -75,7 +72,6 @@ export const writingSystems: IWritingSystems = { 'font': '???', 'exemplars': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'], 'type': WritingSystemType.Vernacular, - 'order': 1, isAudio: false, } ] diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts index 9e6ccf42e3..735af80459 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IWritingSystem.ts @@ -17,6 +17,5 @@ export interface IWritingSystem extends IObjectWithId deletedAt?: string; type: WritingSystemType; exemplars: string[]; - order: number; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/utils.ts b/frontend/viewer/src/lib/utils.ts index b18b94982e..bd6ff14a13 100644 --- a/frontend/viewer/src/lib/utils.ts +++ b/frontend/viewer/src/lib/utils.ts @@ -73,7 +73,6 @@ export function defaultWritingSystem(type: WritingSystemType): IWritingSystem { abbreviation: 'en', font: 'Arial', exemplars: [], - order: 0, deletedAt: undefined, type };