From 3c62972b4fe85b8acd75b5774aca9861de31a6b6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 10 Jan 2025 10:31:11 +0100 Subject: [PATCH 1/4] Allow reordering example sentences --- .../Api/FwDataMiniLcmApi.cs | 55 ++++++++++++++-- .../Api/UpdateProxy/UpdateSenseProxy.cs | 10 +-- .../FwLiteProjectSync.Tests/EntrySyncTests.cs | 4 +- .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 3 +- .../FwLiteProjectSync.Tests/SyncTests.cs | 7 +++ .../FwLiteProjectSync/DryRunMiniLcmApi.cs | 10 ++- .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 18 +++--- ...pshotTests.VerifyChangeModels.verified.txt | 4 ++ ...elSnapshotTests.VerifyDbModel.verified.txt | 3 +- .../Changes/CreateExampleSentenceChange.cs | 3 + backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 30 +++++++-- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 4 +- backend/FwLite/LcmCrdt/QueryHelpers.cs | 4 ++ .../MiniLcm.Tests/UpdateEntryTestsBase.cs | 63 +++++++++++++++++++ backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs | 3 +- .../FwLite/MiniLcm/Models/ExampleSentence.cs | 9 ++- backend/FwLite/MiniLcm/Models/Sense.cs | 2 +- .../SyncHelpers/ExampleSentenceSync.cs | 18 ++++-- frontend/viewer/src/HomeView.svelte | 16 ++--- .../entry-editor/EntryOrSensePicker.svelte | 4 +- .../lib/entry-editor/NewEntryDialog.svelte | 1 + .../object-editors/EntryEditor.svelte | 4 +- frontend/viewer/src/lib/utils.ts | 24 +++---- 23 files changed, 229 insertions(+), 70 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 42af56c24..18e80da67 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -926,6 +926,30 @@ internal void InsertSense(ILexEntry lexEntry, ILexSense lexSense, BetweenPositio lexEntry.SensesOS.Add(lexSense); } + internal void InsertExampleSentence(ILexSense lexSense, ILexExampleSentence lexExample, BetweenPosition? between = null) + { + var previousExampleId = between?.Previous; + var nextExampleId = between?.Next; + + var previousExample = previousExampleId.HasValue ? lexSense.ExamplesOS.FirstOrDefault(s => s.Guid == previousExampleId) : null; + if (previousExample is not null) + { + var insertI = lexSense.ExamplesOS.IndexOf(previousExample) + 1; + lexSense.ExamplesOS.Insert(insertI, lexExample); + return; + } + + var nextExample = nextExampleId.HasValue ? lexSense.ExamplesOS.FirstOrDefault(s => s.Guid == nextExampleId) : null; + if (nextExample is not null) + { + var insertI = lexSense.ExamplesOS.IndexOf(nextExample); + lexSense.ExamplesOS.Insert(insertI, lexExample); + return; + } + + lexSense.ExamplesOS.Add(lexExample); + } + private void ApplySenseToLexSense(Sense sense, ILexSense lexSense) { if (lexSense.MorphoSyntaxAnalysisRA.GetPartOfSpeech()?.Guid != sense.PartOfSpeechId) @@ -1056,9 +1080,10 @@ public Task DeleteSense(Guid entryId, Guid senseId) return Task.FromResult(lcmExampleSentence is null ? null : FromLexExampleSentence(senseId, lcmExampleSentence)); } - internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleSentence) + internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleSentence, BetweenPosition? between = null) { - var lexExampleSentence = LexExampleSentenceFactory.Create(exampleSentence.Id, lexSense); + var lexExampleSentence = LexExampleSentenceFactory.Create(exampleSentence.Id); + InsertExampleSentence(lexSense, lexExampleSentence, between); UpdateLcmMultiString(lexExampleSentence.Example, exampleSentence.Sentence); var freeTranslationType = CmPossibilityRepository.GetObject(CmPossibilityTags.kguidTranFreeTranslation); var translation = CmTranslationFactory.Create(lexExampleSentence, freeTranslationType); @@ -1067,7 +1092,7 @@ internal void CreateExampleSentence(ILexSense lexSense, ExampleSentence exampleS lexExampleSentence.Reference.get_WritingSystem(0)); } - public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? between = null) { if (exampleSentence.Id == default) exampleSentence.Id = Guid.NewGuid(); if (!SenseRepository.TryGetObject(senseId, out var lexSense)) @@ -1075,7 +1100,7 @@ public Task CreateExampleSentence(Guid entryId, Guid senseId, E UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Example Sentence", "Remove example sentence", Cache.ServiceLocator.ActionHandler, - () => CreateExampleSentence(lexSense, exampleSentence)); + () => CreateExampleSentence(lexSense, exampleSentence, between)); return Task.FromResult(FromLexExampleSentence(senseId, ExampleSentenceRepository.GetObject(exampleSentence.Id))); } @@ -1111,6 +1136,28 @@ await Cache.DoUsingNewOrCurrentUOW("Update Example Sentence", return await GetExampleSentence(entryId, senseId, after.Id) ?? throw new NullReferenceException("unable to find example sentence with id " + after.Id); } + public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, BetweenPosition between) + { + if (!EntriesRepository.TryGetObject(entryId, out var lexEntry)) + throw new InvalidOperationException("Entry not found"); + if (!SenseRepository.TryGetObject(senseId, out var lexSense)) + throw new InvalidOperationException("Sense not found"); + if (!ExampleSentenceRepository.TryGetObject(exampleSentenceId, out var lexExample)) + throw new InvalidOperationException("Example sentence not found"); + + ValidateOwnership(lexExample, entryId, senseId); + + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Move Example sentence", + "Move Example sentence back", + Cache.ServiceLocator.ActionHandler, + () => + { + // LibLCM treats an insert as a move if the example sentence is already on the sense + InsertExampleSentence(lexSense, lexExample, between); + }); + return Task.CompletedTask; + } + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { var lexExampleSentence = ExampleSentenceRepository.GetObject(exampleSentenceId); diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs index ea4703301..19e14b121 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs @@ -113,15 +113,9 @@ public override required Guid Id } } - public override IList ExampleSentences + public override List ExampleSentences { - get => - new UpdateListProxy( - sentence => lexboxLcmApi.CreateExampleSentence(sense, sentence), - sentence => lexboxLcmApi.DeleteExampleSentence(sense.Owner.Guid, Id, sentence.Id), - i => new UpdateExampleSentenceProxy(sense.ExamplesOS[i], lexboxLcmApi), - sense.ExamplesOS.Count - ); + get => throw new NotImplementedException(); set => throw new NotImplementedException(); } } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index fcdfe555d..7d29a8a27 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -43,7 +43,9 @@ public async Task CanSyncRandomEntries() var actual = await _fixture.CrdtApi.GetEntry(after.Id); actual.Should().NotBeNull(); actual.Should().BeEquivalentTo(after, options => options - .For(e => e.Senses).Exclude(s => s.Order)); + .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) + ); } [Fact] diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index b69dcb937..399535c89 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -52,7 +52,8 @@ private void ShouldAllBeEquivalentTo(Dictionary crdtEntries, Dictio options => options .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id) - .For(e => e.Senses).Exclude(s => s.Order), + .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order), $"CRDT entry {crdtEntry.Id} was synced with FwData"); } } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index 31d75100c..7f73121cc 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -85,6 +85,7 @@ public async Task FirstSyncJustDoesAnImport() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); } @@ -149,6 +150,7 @@ await crdtApi.CreateEntry(new Entry() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); } @@ -229,6 +231,7 @@ public async Task CreatingAComplexEntryInFwDataSyncsWithoutIssue() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); @@ -313,6 +316,7 @@ await crdtApi.CreateEntry(new Entry() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); } @@ -394,6 +398,7 @@ await crdtApi.CreateEntry(new Entry() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); } @@ -419,6 +424,7 @@ public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) //todo the headword should be changed .For(e => e.Components).Exclude(c => c.ComponentHeadword) @@ -488,6 +494,7 @@ public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth() crdtEntries.Should().BeEquivalentTo(fwdataEntries, options => options .For(e => e.Senses).Exclude(s => s.Order) + .For(e => e.Senses).For(s => s.ExampleSentences).Exclude(s => s.Order) .For(e => e.Components).Exclude(c => c.Id) .For(e => e.ComplexForms).Exclude(c => c.Id)); } diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index c9811602c..3f5a7ebf7 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -252,9 +252,9 @@ public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) return api.GetExampleSentence(entryId, senseId, id); } - public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); + DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence} between {position?.Previous} and {position?.Next}")); return Task.FromResult(exampleSentence); } @@ -278,6 +278,12 @@ public Task UpdateExampleSentence(Guid entryId, return Task.FromResult(after); } + public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + { + DryRunRecords.Add(new DryRunRecord(nameof(MoveExampleSentence), $"Move example sentence {exampleId} between {between.Previous} and {between.Next}")); + return Task.CompletedTask; + } + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { DryRunRecords.Add(new DryRunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index dde5c94c4..ec4966ca0 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -5,6 +5,7 @@ using LcmCrdt; using Microsoft.JSInterop; using MiniLcm; +using MiniLcm.Attributes; using MiniLcm.Models; using Reinforced.Typings; using Reinforced.Typings.Ast.Dependency; @@ -45,15 +46,9 @@ public static void Configure(ConfigurationBuilder builder) From = "@microsoft/dotnet-js-interop", Target = "type {DotNet}" }])); - builder.ExportAsInterface().WithPublicNonStaticProperties(exportBuilder => - { - if (exportBuilder.Member.Name == nameof(Sense.Order)) - { - exportBuilder.Ignore(); - } - }); builder.ExportAsInterfaces([ typeof(Entry), + typeof(Sense), typeof(ExampleSentence), typeof(WritingSystem), typeof(WritingSystems), @@ -61,10 +56,15 @@ public static void Configure(ConfigurationBuilder builder) typeof(SemanticDomain), typeof(ComplexFormType), typeof(ComplexFormComponent), - typeof(MiniLcmJsInvokable.MiniLcmFeatures), ], - exportBuilder => exportBuilder.WithPublicNonStaticProperties()); + exportBuilder => exportBuilder.WithPublicNonStaticProperties(exportBuilder => + { + if (exportBuilder.Member.GetCustomAttribute() is not null) + { + exportBuilder.Ignore(); + } + })); builder.ExportAsEnum().UseString(); builder.ExportAsInterface() .FlattenHierarchy() diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index 578529de3..3cc5eabdd 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -123,6 +123,10 @@ { DerivedType: SetOrderChange, TypeDiscriminator: SetOrderChange:Sense + }, + { + DerivedType: SetOrderChange, + TypeDiscriminator: SetOrderChange:ExampleSentence } ], IgnoreUnrecognizedTypeDiscriminators: false, diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index e33723359..03cc283de 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -117,6 +117,7 @@ Properties: Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd DeletedAt (DateTimeOffset?) + Order (double) Required Reference (string) SenseId (Guid) Required FK Index Sentence (MultiString) Required @@ -208,7 +209,7 @@ Relational:ColumnType: jsonb SnapshotId (no field, Guid?) Shadow FK Index Navigations: - ExampleSentences (IList) Collection ToDependent ExampleSentence + ExampleSentences (List) Collection ToDependent ExampleSentence Keys: Id PK Foreign keys: diff --git a/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs b/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs index d99dbe6c4..b0f3ef029 100644 --- a/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs @@ -12,6 +12,7 @@ public CreateExampleSentenceChange(ExampleSentence exampleSentence, Guid senseId { exampleSentence.Id = EntityId; SenseId = senseId; + Order = exampleSentence.Order; Sentence = exampleSentence.Sentence; Translation = exampleSentence.Translation; Reference = exampleSentence.Reference; @@ -24,6 +25,7 @@ private CreateExampleSentenceChange(Guid entityId, Guid senseId) : base(entityId } public Guid SenseId { get; init; } + public double Order { get; set; } public MultiString? Sentence { get; set; } public MultiString? Translation { get; set; } public string? Reference { get; set; } @@ -34,6 +36,7 @@ public override async ValueTask NewEntity(Commit commit, Change { Id = EntityId, SenseId = SenseId, + Order = Order, Sentence = Sentence ?? new MultiString(), Translation = Translation ?? new MultiString(), Reference = Reference, diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 44fa6fcd0..6c04a2847 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -336,7 +336,7 @@ private IEnumerable CreateEntryChanges(Entry entry, Dictionary CreateEntryChanges(Entry entry, Dictionary CreateSenseChanges(Guid entryId, Sense s } yield return new CreateSenseChange(sense, entryId); - foreach (var change in sense.ExampleSentences.Select(sentence => - new CreateExampleSentenceChange(sentence, sense.Id))) + var exampleOrder = 1; + foreach (var exampleSentence in sense.ExampleSentences) { - yield return change; + if (exampleSentence.Order != default) // we don't anticipate this being necessary, so we'll be strict for now + throw new InvalidOperationException("Order should not be provided when creating an example sentence"); + exampleSentence.Order = exampleOrder++; + yield return new CreateExampleSentenceChange(exampleSentence, sense.Id); } } @@ -536,8 +543,13 @@ public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomai public async Task CreateExampleSentence(Guid entryId, Guid senseId, - ExampleSentence exampleSentence) + ExampleSentence exampleSentence, + BetweenPosition? between = null) { + if (exampleSentence.Order != default) // we don't anticipate this being necessary, so we'll be strict for now + throw new InvalidOperationException("Order should not be provided when creating an example sentence"); + + exampleSentence.Order = await OrderPicker.PickOrder(ExampleSentences.Where(s => s.SenseId == senseId), between); await dataModel.AddChange(ClientId, new CreateExampleSentenceChange(exampleSentence, senseId)); return await dataModel.GetLatest(exampleSentence.Id) ?? throw new NullReferenceException(); } @@ -570,6 +582,12 @@ public async Task UpdateExampleSentence(Guid entryId, return await GetExampleSentence(entryId, senseId, after.Id) ?? throw new NullReferenceException(); } + public async Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + { + var order = await OrderPicker.PickOrder(ExampleSentences.Where(s => s.SenseId == senseId), between); + await dataModel.AddChange(ClientId, new Changes.SetOrderChange(exampleId, order)); + } + public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { await dataModel.AddChange(ClientId, new DeleteChange(exampleSentenceId)); diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 5a7478296..c9131e10f 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -199,7 +199,9 @@ public static void ConfigureCrdt(CrdtConfig config) .Add() .Add() .Add() - .Add>(); + .Add>() + .Add>() + ; } public static Type[] AllChangeTypes() diff --git a/backend/FwLite/LcmCrdt/QueryHelpers.cs b/backend/FwLite/LcmCrdt/QueryHelpers.cs index b2564ba58..ae07b18d6 100644 --- a/backend/FwLite/LcmCrdt/QueryHelpers.cs +++ b/backend/FwLite/LcmCrdt/QueryHelpers.cs @@ -5,6 +5,10 @@ public static class QueryHelpers public static void ApplySortOrder(this Entry entry) { entry.Senses.ApplySortOrder(); + foreach (var sense in entry.Senses) + { + sense.ExampleSentences.ApplySortOrder(); + } } public static void ApplySortOrder(this List items) where T : IOrderable diff --git a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs index 1c7e21886..4c49afc48 100644 --- a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs @@ -158,4 +158,67 @@ public async Task UpdateEntry_CanReorderSenses(string before, string after, stri actualOrderValues.Should().Be(expectedOrderValues); } } + + [Theory] + [InlineData("a,b", "a,b,c,d", "1,2,3,4")] // append + [InlineData("a,2", "c,a,b", "0,1,2")] // single prepend + [InlineData("a,b", "d,c,a,b", "0,0.5,1,2")] // multi prepend + [InlineData("a,b,c,d", "d,a,b,c", "0,1,2,3")] // move to back + [InlineData("a,b,c,d", "b,c,d,a", "2,3,4,5")] // move to front + [InlineData("a,b,c,d,e", "a,b,e,c,d", "1,2,2.5,3,4")] // move to middle + [InlineData("a,b,c", "c,b,a", "3,4,5")] // reverse + [InlineData("a,b,c,d", "d,b,c,a", "1,2,3,4")] // swap + public async Task UpdateEntry_CanReorderExampleSentence(string before, string after, string expectedOrderValues) + { + // arrange + var entryId = Guid.NewGuid(); + var senseId = Guid.NewGuid(); + var exampleIds = before.Split(',').Concat(after.Split(',')).Distinct() + .ToDictionary(i => i, _ => Guid.NewGuid()); + var beforeExamples = before.Split(',').Select(i => new ExampleSentence() { Id = exampleIds[i], SenseId = senseId, Sentence = { { "en", i } } }).ToList(); + var afterExamples = after.Split(',').Select(i => new ExampleSentence() { Id = exampleIds[i], SenseId = senseId, Sentence = { { "en", i } } }).ToList(); + + var beforeEntry = await Api.CreateEntry(new() + { + Id = entryId, + LexemeForm = { { "en", "order" } }, + Senses = [ + new Sense + { + Id = senseId, + EntryId = entryId, + ExampleSentences = beforeExamples, + } + ] + }); + var beforeSense = beforeEntry!.Senses[0]; + + var afterEntry = beforeEntry!.Copy(); + var afterSense = afterEntry.Senses[0]; + afterSense.ExampleSentences = afterExamples; + + // sanity checks + beforeSense.ExampleSentences.Should().BeEquivalentTo(beforeExamples, options => options.WithStrictOrdering()); + if (!ApiUsesImplicitOrdering) + { + beforeSense.ExampleSentences.Select(s => s.Order).Should() + .BeEquivalentTo(Enumerable.Range(1, beforeExamples.Count), options => options.WithStrictOrdering()); + } + + // act + await Api.UpdateEntry(beforeEntry, afterEntry); + var actualEntry = await Api.GetEntry(afterEntry.Id); + var actual = actualEntry!.Senses[0]; + + // assert + actual.Should().NotBeNull(); + actual.ExampleSentences.Should().BeEquivalentTo(afterSense.ExampleSentences, + options => options.WithStrictOrdering().Excluding(s => s.Order)); + + if (!ApiUsesImplicitOrdering) + { + var actualOrderValues = string.Join(',', actual.ExampleSentences.Select(s => s.Order.ToString(CultureInfo.GetCultureInfo("en-US")))); + actualOrderValues.Should().Be(expectedOrderValues); + } + } } diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 1dfe7c287..70fee8261 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -57,7 +57,7 @@ Task UpdateWritingSystem(WritingSystemId id, #endregion #region ExampleSentence - Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence); + Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null); Task UpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, @@ -66,6 +66,7 @@ Task UpdateExampleSentence(Guid entryId, Guid senseId, ExampleSentence before, ExampleSentence after); + Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, BetweenPosition position); Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId); #endregion diff --git a/backend/FwLite/MiniLcm/Models/ExampleSentence.cs b/backend/FwLite/MiniLcm/Models/ExampleSentence.cs index dde441994..14e3352be 100644 --- a/backend/FwLite/MiniLcm/Models/ExampleSentence.cs +++ b/backend/FwLite/MiniLcm/Models/ExampleSentence.cs @@ -1,8 +1,12 @@ -namespace MiniLcm.Models; +using MiniLcm.Attributes; -public class ExampleSentence : IObjectWithId +namespace MiniLcm.Models; + +public class ExampleSentence : IObjectWithId, IOrderable { public virtual Guid Id { get; set; } + [MiniLcmInternal] + public double Order { get; set; } public virtual MultiString Sentence { get; set; } = new(); public virtual MultiString Translation { get; set; } = new(); public virtual string? Reference { get; set; } = null; @@ -26,6 +30,7 @@ public IObjectWithId Copy() return new ExampleSentence() { Id = Id, + Order = Order, DeletedAt = DeletedAt, SenseId = SenseId, Sentence = Sentence.Copy(), diff --git a/backend/FwLite/MiniLcm/Models/Sense.cs b/backend/FwLite/MiniLcm/Models/Sense.cs index d774a0fae..4464dee00 100644 --- a/backend/FwLite/MiniLcm/Models/Sense.cs +++ b/backend/FwLite/MiniLcm/Models/Sense.cs @@ -14,7 +14,7 @@ public class Sense : IObjectWithId, IOrderable public virtual string PartOfSpeech { get; set; } = string.Empty; public virtual Guid? PartOfSpeechId { get; set; } public virtual IList SemanticDomains { get; set; } = []; - public virtual IList ExampleSentences { get; set; } = []; + public virtual List ExampleSentences { get; set; } = []; public Guid[] GetReferences() { diff --git a/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs index c2cdf2d2e..d663318a7 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs @@ -11,7 +11,7 @@ public static async Task Sync(Guid entryId, IList afterExampleSentences, IMiniLcmApi api) { - return await DiffCollection.Diff( + return await DiffCollection.DiffOrderable( beforeExampleSentences, afterExampleSentences, new ExampleSentencesDiffApi(api, entryId, senseId)); @@ -50,21 +50,27 @@ public static async Task Sync(Guid entryId, return new UpdateObjectInput(patchDocument); } - private class ExampleSentencesDiffApi(IMiniLcmApi api, Guid entryId, Guid senseId) : ObjectWithIdCollectionDiffApi + private class ExampleSentencesDiffApi(IMiniLcmApi api, Guid entryId, Guid senseId) : IOrderableCollectionDiffApi { - public override async Task Add(ExampleSentence afterExampleSentence) + public async Task Add(ExampleSentence afterExampleSentence, BetweenPosition between) { - await api.CreateExampleSentence(entryId, senseId, afterExampleSentence); + await api.CreateExampleSentence(entryId, senseId, afterExampleSentence, between); return 1; } - public override async Task Remove(ExampleSentence beforeExampleSentence) + public async Task Move(ExampleSentence example, BetweenPosition between) + { + await api.MoveExampleSentence(entryId, senseId, example.Id, between); + return 1; + } + + public async Task Remove(ExampleSentence beforeExampleSentence) { await api.DeleteExampleSentence(entryId, senseId, beforeExampleSentence.Id); return 1; } - public override Task Replace(ExampleSentence beforeExampleSentence, ExampleSentence afterExampleSentence) + public Task Replace(ExampleSentence beforeExampleSentence, ExampleSentence afterExampleSentence) { return Sync(entryId, senseId, beforeExampleSentence, afterExampleSentence, api); } diff --git a/frontend/viewer/src/HomeView.svelte b/frontend/viewer/src/HomeView.svelte index 5b9695cf3..0ce6f4cb6 100644 --- a/frontend/viewer/src/HomeView.svelte +++ b/frontend/viewer/src/HomeView.svelte @@ -176,13 +176,15 @@ {/each} - - -
-
-
-
+ + + +
+
+
+
+
{#if !projects.some(p => p.name === exampleProjectName) || $isDev} createProject(exampleProjectName)}>
diff --git a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte index b7d7334cc..7ec9d9603 100644 --- a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte +++ b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte @@ -14,7 +14,7 @@ import { createEventDispatcher, getContext } from 'svelte'; import { useLexboxApi } from '../services/service-provider'; import { deriveAsync } from '../utils/time'; - import { defaultSense, firstDef, firstGloss, glosses, headword, randomId } from '../utils'; + import { defaultSense, firstDef, firstGloss, glosses, headword } from '../utils'; import { useProjectCommands } from '../commands'; import type { SaveHandler } from '../services/save-event-service'; import {SortField} from '$lib/dotnet-types'; @@ -76,7 +76,7 @@ } async function onClickAddSense(entry: IEntry): Promise { - const newSense = defaultSense(randomId()); + const newSense = defaultSense(entry.id); const savedSense = await saveHandler(() => lexboxApi.createSense(entry.id, newSense)); entry.senses = [...entry.senses, savedSense]; selectedSense = savedSense; diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index a3a4c3720..1331c9b00 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -24,6 +24,7 @@ async function createEntry(e: Event, closeDialog: () => void) { e.preventDefault(); loading = true; + console.debug('Creating entry', entry); await saveHandler(() => lexboxApi.createEntry(entry)); dispatch('created', {entry}); if (requester) { diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index 00837a772..531ba11b9 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -26,13 +26,13 @@ export let entry: IEntry; function addSense() { - const sense = defaultSense(); + const sense = defaultSense(entry.id); highlightedEntity = sense; entry.senses = [...entry.senses, sense]; } function addExample(sense: ISense) { - const sentence = defaultExampleSentence(); + const sentence = defaultExampleSentence(sense.id); highlightedEntity = sentence; sense.exampleSentences = [...sense.exampleSentences, sentence]; entry = entry; // examples counts are not updated without this diff --git a/frontend/viewer/src/lib/utils.ts b/frontend/viewer/src/lib/utils.ts index babfbf95e..18cb1d68a 100644 --- a/frontend/viewer/src/lib/utils.ts +++ b/frontend/viewer/src/lib/utils.ts @@ -1,6 +1,6 @@ -import type { IEntry, IExampleSentence, IMultiString, ISense, IWritingSystem, IWritingSystems } from '$lib/dotnet-types'; +import type {IEntry, IExampleSentence, IMultiString, ISense, IWritingSystem, IWritingSystems} from '$lib/dotnet-types'; -import type { WritingSystemSelection } from './config-types'; +import type {WritingSystemSelection} from './config-types'; export function firstVal(multi: IMultiString): string | undefined { return Object.values(multi).find(value => !!value); @@ -82,14 +82,6 @@ export function pickWritingSystems( return []; } -const emptyIdPrefix = '00000000-0000-0000-0000-'; -export function emptyId(): string { - return emptyIdPrefix + crypto.randomUUID().slice(emptyIdPrefix.length); -} -export function isEmptyId(id: string): boolean { - return id.startsWith(emptyIdPrefix); -} - export function randomId(): string { return crypto.randomUUID(); } @@ -108,10 +100,10 @@ export function defaultEntry(): IEntry { }; } -export function defaultSense(id?: string): ISense { +export function defaultSense(entryId: string): ISense { return { - id: id ?? emptyId(), - entryId: '', + id: randomId(), + entryId, definition: {}, gloss: {}, partOfSpeechId: undefined, @@ -121,10 +113,10 @@ export function defaultSense(id?: string): ISense { }; } -export function defaultExampleSentence(): IExampleSentence { +export function defaultExampleSentence(senseId: string): IExampleSentence { return { - id: emptyId(), - senseId: '', + id: randomId(), + senseId, sentence: {}, translation: {}, reference: '', From 2edd9ba9deef5718bb8850131e0083ecdccf2fa6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 13 Jan 2025 13:51:12 +0100 Subject: [PATCH 2/4] Fix confusing (but not buggy) test case --- backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs index 4c49afc48..5268f2a83 100644 --- a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs @@ -109,7 +109,7 @@ public async Task UpdateEntry_CanUseSameVersionMultipleTimes() [Theory] [InlineData("a,b", "a,b,c,d", "1,2,3,4")] // append - [InlineData("a,2", "c,a,b", "0,1,2")] // single prepend + [InlineData("a,b", "c,a,b", "0,1,2")] // single prepend [InlineData("a,b", "d,c,a,b", "0,0.5,1,2")] // multi prepend [InlineData("a,b,c,d", "d,a,b,c", "0,1,2,3")] // move to back [InlineData("a,b,c,d", "b,c,d,a", "2,3,4,5")] // move to front @@ -161,7 +161,7 @@ public async Task UpdateEntry_CanReorderSenses(string before, string after, stri [Theory] [InlineData("a,b", "a,b,c,d", "1,2,3,4")] // append - [InlineData("a,2", "c,a,b", "0,1,2")] // single prepend + [InlineData("a,b", "c,a,b", "0,1,2")] // single prepend [InlineData("a,b", "d,c,a,b", "0,0.5,1,2")] // multi prepend [InlineData("a,b,c,d", "d,a,b,c", "0,1,2,3")] // move to back [InlineData("a,b,c,d", "b,c,d,a", "2,3,4,5")] // move to front From 228ad0f22206500a4a80f5acab5389646437108d Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 13 Jan 2025 13:57:18 +0100 Subject: [PATCH 3/4] Change misleading variable names --- backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs index 5268f2a83..eb32a57b9 100644 --- a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs @@ -121,9 +121,9 @@ public async Task UpdateEntry_CanReorderSenses(string before, string after, stri // arrange var entryId = Guid.NewGuid(); var senseIds = before.Split(',').Concat(after.Split(',')).Distinct() - .ToDictionary(i => i, _ => Guid.NewGuid()); - var beforeSenses = before.Split(',').Select(i => new Sense() { Id = senseIds[i], EntryId = entryId, Gloss = { { "en", i } } }).ToList(); - var afterSenses = after.Split(',').Select(i => new Sense() { Id = senseIds[i], EntryId = entryId, Gloss = { { "en", i } } }).ToList(); + .ToDictionary(@char => @char, _ => Guid.NewGuid()); + var beforeSenses = before.Split(',').Select(@char => new Sense() { Id = senseIds[@char], EntryId = entryId, Gloss = { { "en", @char } } }).ToList(); + var afterSenses = after.Split(',').Select(@char => new Sense() { Id = senseIds[@char], EntryId = entryId, Gloss = { { "en", @char } } }).ToList(); var beforeEntry = await Api.CreateEntry(new() { @@ -174,9 +174,9 @@ public async Task UpdateEntry_CanReorderExampleSentence(string before, string af var entryId = Guid.NewGuid(); var senseId = Guid.NewGuid(); var exampleIds = before.Split(',').Concat(after.Split(',')).Distinct() - .ToDictionary(i => i, _ => Guid.NewGuid()); - var beforeExamples = before.Split(',').Select(i => new ExampleSentence() { Id = exampleIds[i], SenseId = senseId, Sentence = { { "en", i } } }).ToList(); - var afterExamples = after.Split(',').Select(i => new ExampleSentence() { Id = exampleIds[i], SenseId = senseId, Sentence = { { "en", i } } }).ToList(); + .ToDictionary(@char => @char, _ => Guid.NewGuid()); + var beforeExamples = before.Split(',').Select(@char => new ExampleSentence() { Id = exampleIds[@char], SenseId = senseId, Sentence = { { "en", @char } } }).ToList(); + var afterExamples = after.Split(',').Select(@char => new ExampleSentence() { Id = exampleIds[@char], SenseId = senseId, Sentence = { { "en", @char } } }).ToList(); var beforeEntry = await Api.CreateEntry(new() { From af5d72f78e540c4a91b71652d3b57d645ac3c7ab Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 13 Jan 2025 14:03:16 +0100 Subject: [PATCH 4/4] Add doc comments to CreateSense and CreateExampleSentence --- backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 70fee8261..b8ad8f887 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -47,6 +47,13 @@ Task UpdateWritingSystem(WritingSystemId id, #endregion #region Sense + /// + /// Creates the provided sense and adds it to the specified entry + /// + /// The ID of the sense's parent entry + /// The sense to create + /// Where the sense should be inserted in the entry's list of senses. If null it will be appended to the end of the list. + /// Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null); Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update); Task UpdateSense(Guid entryId, Sense before, Sense after); @@ -57,6 +64,14 @@ Task UpdateWritingSystem(WritingSystemId id, #endregion #region ExampleSentence + /// + /// Creates the provided example sentence and adds it to the specified sense + /// + /// The ID of the sense's parent entry + /// The ID of example sentence's parent sense + /// The example sentence to create + /// Where the example sentence should be inserted in the sense's list of example sentences. If null it will be appended to the end of the list. + /// Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null); Task UpdateExampleSentence(Guid entryId, Guid senseId,