From 3e1cd7001f0a9d883ca909af1f54f62a418b2fb3 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 12 Jun 2024 14:04:38 -0600 Subject: [PATCH 01/38] define parts of speech models in MiniLcm, implement tests and updates for the FwDataBridge pull part of speech helper methods into their own utility class rewrite semantic domain implementation to support them being objects that are referenced, instead of just being a string. prevent json patch changes from having index references, this will avoid conflicts where the index changes due to merges. introduce json patch rewriting to convert patches into specific changes. Rewrite changes to Sense.PartOfSpeechId into SetPartOfSpeechChange. allow creating parts of speech as CRDTs and setup a PoC of pre seeding them. allow creating semantic domains and referencing them in senses, rewrite json patch to change semantic domains of senses. Add tests for creating senses with and without semantic domains, and with and without part of speeches. --- LexBox.sln | 7 + .../Fixtures/ProjectLoaderFixture.cs | 29 ++++ .../FwDataMiniLcmBridge.Tests.csproj | 30 ++++ .../PartOfSpeechTests.cs | 52 ++++++ .../SemanticDomainTests.cs | 74 +++++++++ .../Api/FwDataMiniLcmApi.cs | 45 ++++- .../Api/MorphoSyntaxExtensions.cs | 50 ++++++ .../Api/UpdateProxy/UpdateEntryProxy.cs | 3 +- .../Api/UpdateProxy/UpdateListProxy.cs | 55 +++++- .../Api/UpdateProxy/UpdateSenseProxy.cs | 40 ++++- .../FwDataMiniLcmBridge/FwDataBridgeKernel.cs | 2 + backend/FwDataMiniLcmBridge/FwDataFactory.cs | 11 +- .../FwDataMiniLcmBridge.csproj | 4 + .../LcmUtils/ProjectLoader.cs | 14 +- .../Changes/JsonPatchChangeTests.cs | 48 ++++++ .../LcmCrdt.Tests/JsonPatchRewriteTests.cs | 129 ++++++++++++++ backend/LcmCrdt.Tests/LexboxApiTests.cs | 75 ++++++++- .../Changes/AddSemanticDomainChange.cs | 24 +++ .../Changes/CreatePartOfSpeechChange.cs | 19 +++ .../Changes/CreateSemanticDomainChange.cs | 20 +++ backend/LcmCrdt/Changes/CreateSenseChange.cs | 9 +- backend/LcmCrdt/Changes/JsonPatchChange.cs | 33 +++- .../Changes/RemoveSemanticDomainChange.cs | 15 ++ .../Changes/ReplaceSemanticDomainChange.cs | 27 +++ .../LcmCrdt/Changes/SetPartOfSpeechChange.cs | 19 +++ backend/LcmCrdt/CrdtLexboxApi.cs | 6 +- backend/LcmCrdt/LcmCrdtKernel.cs | 35 ++-- backend/LcmCrdt/Objects/PartOfSpeech.cs | 46 +++++ backend/LcmCrdt/Objects/SemanticDomain.cs | 37 +++++ backend/LcmCrdt/Objects/Sense.cs | 56 ++++++- backend/LcmCrdt/ProjectsService.cs | 13 +- backend/LcmCrdt/Utils/JsonPatchRewriter.cs | 157 ++++++++++++++++++ backend/LfClassicData/LfClassicLexboxApi.cs | 4 +- backend/LocalWebApp/Routes/ProjectRoutes.cs | 2 +- backend/LocalWebApp/appsettings.json | 3 +- backend/MiniLcm/ILexboxApi.cs | 23 +++ backend/MiniLcm/MultiString.cs | 5 + backend/MiniLcm/PartOfSpeech.cs | 7 + backend/MiniLcm/SemanticDomain.cs | 8 + backend/MiniLcm/Sense.cs | 3 +- 40 files changed, 1188 insertions(+), 51 deletions(-) create mode 100644 backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs create mode 100644 backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj create mode 100644 backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs create mode 100644 backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs create mode 100644 backend/FwDataMiniLcmBridge/Api/MorphoSyntaxExtensions.cs create mode 100644 backend/LcmCrdt.Tests/Changes/JsonPatchChangeTests.cs create mode 100644 backend/LcmCrdt.Tests/JsonPatchRewriteTests.cs create mode 100644 backend/LcmCrdt/Changes/AddSemanticDomainChange.cs create mode 100644 backend/LcmCrdt/Changes/CreatePartOfSpeechChange.cs create mode 100644 backend/LcmCrdt/Changes/CreateSemanticDomainChange.cs create mode 100644 backend/LcmCrdt/Changes/RemoveSemanticDomainChange.cs create mode 100644 backend/LcmCrdt/Changes/ReplaceSemanticDomainChange.cs create mode 100644 backend/LcmCrdt/Changes/SetPartOfSpeechChange.cs create mode 100644 backend/LcmCrdt/Objects/PartOfSpeech.cs create mode 100644 backend/LcmCrdt/Objects/SemanticDomain.cs create mode 100644 backend/LcmCrdt/Utils/JsonPatchRewriter.cs create mode 100644 backend/MiniLcm/PartOfSpeech.cs create mode 100644 backend/MiniLcm/SemanticDomain.cs diff --git a/LexBox.sln b/LexBox.sln index 6ec82a3cc..0e8259294 100644 --- a/LexBox.sln +++ b/LexBox.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Crdt.Core", "backend\harmon EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwDataMiniLcmBridge", "backend\FwDataMiniLcmBridge\FwDataMiniLcmBridge.csproj", "{279197B6-EC06-4DE0-94F8-625379C3AD83}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwDataMiniLcmBridge.Tests", "backend\FwDataMiniLcmBridge.Tests\FwDataMiniLcmBridge.Tests.csproj", "{B0299A49-C0B2-4553-A72E-1670D4CB5138}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -101,6 +103,10 @@ Global {279197B6-EC06-4DE0-94F8-625379C3AD83}.Debug|Any CPU.Build.0 = Debug|Any CPU {279197B6-EC06-4DE0-94F8-625379C3AD83}.Release|Any CPU.ActiveCfg = Release|Any CPU {279197B6-EC06-4DE0-94F8-625379C3AD83}.Release|Any CPU.Build.0 = Release|Any CPU + {B0299A49-C0B2-4553-A72E-1670D4CB5138}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0299A49-C0B2-4553-A72E-1670D4CB5138}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0299A49-C0B2-4553-A72E-1670D4CB5138}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0299A49-C0B2-4553-A72E-1670D4CB5138}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {E8BB768B-C3DC-4BE6-9B9F-82319E05AF86} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} @@ -111,5 +117,6 @@ Global {740C8FF5-8006-4047-8C52-53873C2DD7C4} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {8B54FFB5-0BDF-403E-83CC-A3B3861EC507} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} {279197B6-EC06-4DE0-94F8-625379C3AD83} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} + {B0299A49-C0B2-4553-A72E-1670D4CB5138} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090} EndGlobalSection EndGlobal diff --git a/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs b/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs new file mode 100644 index 000000000..164a4d4d9 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs @@ -0,0 +1,29 @@ +using FwDataMiniLcmBridge.Api; +using Microsoft.Extensions.DependencyInjection; + +namespace FwDataMiniLcmBridge.Tests.Fixtures; + +public class ProjectLoaderFixture : IDisposable +{ + private readonly FwDataFactory _fwDataFactory; + private ServiceProvider _serviceProvider; + + public ProjectLoaderFixture() + { + //todo make mock of IProjectLoader so we can load from test projects + var provider = new ServiceCollection().AddFwDataBridge().BuildServiceProvider(); + _serviceProvider = provider; + _fwDataFactory = provider.GetRequiredService(); + } + + public FwDataMiniLcmApi CreateApi(string projectName) + { + return _fwDataFactory.GetFwDataMiniLcmApi(projectName, false); + } + + public void Dispose() + { + _fwDataFactory.Dispose(); + _serviceProvider.Dispose(); + } +} diff --git a/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj new file mode 100644 index 000000000..e797a6ec7 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs b/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs new file mode 100644 index 000000000..54e4e17b1 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs @@ -0,0 +1,52 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; +using MiniLcm; + +namespace FwDataMiniLcmBridge.Tests; + +public class PartOfSpeechTests: IClassFixture +{ + ProjectLoaderFixture _fixture; + public PartOfSpeechTests(ProjectLoaderFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task GetPartsOfSpeech_ReturnsAllPartsOfSpeech() + { + var api = _fixture.CreateApi("sena-3"); + var partOfSpeeches = await api.GetPartsOfSpeech().ToArrayAsync(); + partOfSpeeches.Should().AllSatisfy(po => po.Id.Should().NotBe(Guid.Empty)); + } + + [Fact] + public async Task Sense_HasPartOfSpeech() + { + var api = _fixture.CreateApi("sena-3"); + var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => !string.IsNullOrEmpty(s.PartOfSpeech))); + var sense = entry.Senses.First(s => !string.IsNullOrEmpty(s.PartOfSpeech)); + sense.PartOfSpeech.Should().NotBeNullOrEmpty(); + sense.PartOfSpeechId.Should().NotBeNull(); + } + + [Fact] + public async Task Sense_UpdatesPartOfSpeech() + { + var api = _fixture.CreateApi("sena-3"); + var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => !string.IsNullOrEmpty(s.PartOfSpeech))); + var sense = entry.Senses.First(s => !string.IsNullOrEmpty(s.PartOfSpeech)); + var newPartOfSpeech = await api.GetPartsOfSpeech().FirstAsync(po => po.Id != sense.PartOfSpeechId); + + var update = api.CreateUpdateBuilder() + .Set(s => s.PartOfSpeech, newPartOfSpeech.Name["en"])//this won't actually update the part of speech, but it shouldn't cause an issue either. + .Set(s => s.PartOfSpeechId, newPartOfSpeech.Id) + .Build(); + await api.UpdateSense(entry.Id, sense.Id, update); + + entry = await api.GetEntry(entry.Id); + ArgumentNullException.ThrowIfNull(entry); + var updatedSense = entry.Senses.First(s => s.Id == sense.Id); + updatedSense.PartOfSpeechId.Should().Be(newPartOfSpeech.Id); + updatedSense.PartOfSpeech.Should().NotBe(sense.PartOfSpeech);//the part of speech here is whatever the default is for the project, not english. + } +} diff --git a/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs b/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs new file mode 100644 index 000000000..0320bc642 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs @@ -0,0 +1,74 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; +using MiniLcm; + +namespace FwDataMiniLcmBridge.Tests; + +public class SemanticDomainTests(ProjectLoaderFixture fixture) : IClassFixture +{ + [Fact] + public async Task GetSemanticDomains_ReturnsAllSemanticDomains() + { + var api = fixture.CreateApi("sena-3"); + var semanticDomains = await api.GetSemanticDomains().ToArrayAsync(); + semanticDomains.Should().AllSatisfy(sd => + { + sd.Id.Should().NotBe(Guid.Empty); + sd.Name.Values.Should().NotBeEmpty(); + sd.Code.Should().NotBeEmpty(); + }); + } + + [Fact] + public async Task Sense_HasSemanticDomains() + { + var api = fixture.CreateApi("sena-3"); + var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => s.SemanticDomains.Any())); + var sense = entry.Senses.First(s => s.SemanticDomains.Any()); + sense.SemanticDomains.Should().NotBeEmpty(); + sense.SemanticDomains.Should().AllSatisfy(sd => + { + sd.Id.Should().NotBe(Guid.Empty); + sd.Name.Values.Should().NotBeEmpty(); + sd.Code.Should().NotBeEmpty(); + }); + } + + [Fact] + public async Task Sense_AddSemanticDomain() + { + var api = fixture.CreateApi("sena-3"); + var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => s.SemanticDomains.Any())); + var sense = entry.Senses.First(s => s.SemanticDomains.Any()); + var currentSemanticDomain = sense.SemanticDomains.First(); + var newSemanticDomain = await api.GetSemanticDomains().FirstAsync(sd => sd.Id != currentSemanticDomain.Id); + + var update = api.CreateUpdateBuilder() + .Add(s => s.SemanticDomains, newSemanticDomain) + .Build(); + await api.UpdateSense(entry.Id, sense.Id, update); + + entry = await api.GetEntry(entry.Id); + ArgumentNullException.ThrowIfNull(entry); + var updatedSense = entry.Senses.First(s => s.Id == sense.Id); + updatedSense.SemanticDomains.Select(sd => sd.Id).Should().Contain(newSemanticDomain.Id); + } + + [Fact] + public async Task Sense_RemoveSemanticDomain() + { + var api = fixture.CreateApi("sena-3"); + var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => s.SemanticDomains.Any())); + var sense = entry.Senses.First(s => s.SemanticDomains.Any()); + var domainToRemove = sense.SemanticDomains[0]; + + var update = api.CreateUpdateBuilder() + .Remove(s => s.SemanticDomains, 0) + .Build(); + await api.UpdateSense(entry.Id, sense.Id, update); + + entry = await api.GetEntry(entry.Id); + ArgumentNullException.ThrowIfNull(entry); + var updatedSense = entry.Senses.First(s => s.Id == sense.Id); + updatedSense.SemanticDomains.Select(sd => sd.Id).Should().NotContain(domainToRemove.Id); + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 2f862442c..88775257d 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -34,6 +34,12 @@ public class FwDataMiniLcmApi(LcmCache cache, bool onCloseSave, ILogger(); + private readonly ICmPossibilityRepository _cmPossibilityRepository = + cache.ServiceLocator.GetInstance(); + private readonly IPartOfSpeechRepository _partOfSpeechRepository = + cache.ServiceLocator.GetInstance(); + private readonly ICmSemanticDomainRepository _semanticDomainRepository = + cache.ServiceLocator.GetInstance(); private readonly ICmTranslationFactory _cmTranslationFactory = cache.ServiceLocator.GetInstance(); @@ -158,6 +164,32 @@ public Task UpdateWritingSystem(WritingSystemId id, WritingSystem throw new NotImplementedException(); } + public async IAsyncEnumerable GetPartsOfSpeech() + { + foreach (var partOfSpeech in _partOfSpeechRepository.AllInstances()) + { + yield return new PartOfSpeech { Id = partOfSpeech.Guid, Name = FromLcmMultiString(partOfSpeech.Name) }; + } + } + + public async IAsyncEnumerable GetSemanticDomains() + { + foreach (var semanticDomain in _semanticDomainRepository.AllInstances()) + { + yield return new SemanticDomain + { + Id = semanticDomain.Guid, + Name = FromLcmMultiString(semanticDomain.Name), + Code = semanticDomain.OcmCodes + }; + } + } + + internal ICmSemanticDomain GetLcmSemanticDomain(SemanticDomain semanticDomain) + { + return _semanticDomainRepository.GetObject(semanticDomain.Id); + } + private Entry FromLexEntry(ILexEntry entry) { return new Entry @@ -173,15 +205,22 @@ private Entry FromLexEntry(ILexEntry entry) private Sense FromLexSense(ILexSense sense) { - return new Sense + var s = new Sense { Id = sense.Guid, Gloss = FromLcmMultiString(sense.Gloss), Definition = FromLcmMultiString(sense.Definition), - PartOfSpeech = sense.SenseTypeRA?.Name.BestAnalysisVernacularAlternative.Text ?? string.Empty, - SemanticDomain = sense.SemanticDomainsRC.Select(s => s.OcmCodes).ToList(), + PartOfSpeech = sense.MorphoSyntaxAnalysisRA?.InterlinearName ?? "", + PartOfSpeechId = sense.MorphoSyntaxAnalysisRA?.GetPartOfSpeech()?.Guid, + SemanticDomains = sense.SemanticDomainsRC.Select(s => new SemanticDomain + { + Id = s.Guid, + Name = FromLcmMultiString(s.Name), + Code = s.OcmCodes + }).ToList(), ExampleSentences = sense.ExamplesOS.Select(FromLexExampleSentence).ToList() }; + return s; } private ExampleSentence FromLexExampleSentence(ILexExampleSentence sentence) diff --git a/backend/FwDataMiniLcmBridge/Api/MorphoSyntaxExtensions.cs b/backend/FwDataMiniLcmBridge/Api/MorphoSyntaxExtensions.cs new file mode 100644 index 000000000..91295d418 --- /dev/null +++ b/backend/FwDataMiniLcmBridge/Api/MorphoSyntaxExtensions.cs @@ -0,0 +1,50 @@ +using SIL.LCModel; + +namespace FwDataMiniLcmBridge.Api; + +public static class MorphoSyntaxExtensions +{ + public static void SetMsaPartOfSpeech(this IMoMorphSynAnalysis msa, IPartOfSpeech pos) + { + switch (msa.ClassID) + { + case MoDerivAffMsaTags.kClassId: + //todo there's a toPartofSpeech in the msa, not sure what we do with that. + ((IMoDerivAffMsa)msa).FromPartOfSpeechRA = pos; + break; + case MoDerivStepMsaTags.kClassId: + ((IMoDerivStepMsa)msa).PartOfSpeechRA = pos; + break; + case MoInflAffMsaTags.kClassId: + ((IMoInflAffMsa)msa).PartOfSpeechRA = pos; + break; + case MoStemMsaTags.kClassId: + ((IMoStemMsa)msa).PartOfSpeechRA = pos; + break; + case MoUnclassifiedAffixMsaTags.kClassId: + ((IMoUnclassifiedAffixMsa)msa).PartOfSpeechRA = pos; + break; + default: + throw new NotSupportedException($"Cannot set part of speech for MSA of unknown type: {msa.ClassID}"); + } + } + + public static IPartOfSpeech? GetPartOfSpeech(this IMoMorphSynAnalysis msa) + { + switch (msa.ClassID) + { + case MoDerivAffMsaTags.kClassId: + return ((IMoDerivAffMsa)msa).FromPartOfSpeechRA; + case MoDerivStepMsaTags.kClassId: + return ((IMoDerivStepMsa)msa).PartOfSpeechRA; + case MoInflAffMsaTags.kClassId: + return ((IMoInflAffMsa)msa).PartOfSpeechRA; + case MoStemMsaTags.kClassId: + return ((IMoStemMsa)msa).PartOfSpeechRA; + case MoUnclassifiedAffixMsaTags.kClassId: + return ((IMoUnclassifiedAffixMsa)msa).PartOfSpeechRA; + default: + return null; + } + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs index f48470ca1..741c6e7be 100644 --- a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateEntryProxy.cs @@ -36,7 +36,8 @@ public override IList Senses new UpdateListProxy( sense => lexboxLcmApi.CreateSense(lcmEntry, sense), sense => lexboxLcmApi.DeleteSense(Id, sense.Id), - i => new UpdateSenseProxy(lcmEntry.SensesOS[i], lexboxLcmApi) + i => new UpdateSenseProxy(lcmEntry.SensesOS[i], lexboxLcmApi), + lcmEntry.SensesOS.Count ); set => throw new NotImplementedException(); } diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs index dc21c62bf..7e3f7b411 100644 --- a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs @@ -5,8 +5,12 @@ namespace FwDataMiniLcmBridge.Api.UpdateProxy; public class UpdateListProxy( Action add, Action remove, - Func getAt) : IList + Func getAt, + int count) : IList, IList { + private bool _isSynchronized; + private bool _isFixedSize; + public IEnumerator GetEnumerator() { throw new NotImplementedException(); @@ -22,11 +26,40 @@ public void Add(T item) add(item); } + int IList.Add(object? value) + { + ArgumentNullException.ThrowIfNull(value); + add((T)value); + return 0; + } + public void Clear() { throw new NotImplementedException(); } + bool IList.Contains(object? value) + { + throw new NotImplementedException(); + } + + int IList.IndexOf(object? value) + { + throw new NotImplementedException(); + } + + void IList.Insert(int index, object? value) + { + ArgumentNullException.ThrowIfNull(value); + Insert(index, (T)value); + } + + void IList.Remove(object? value) + { + ArgumentNullException.ThrowIfNull(value); + Remove((T)value); + } + public bool Contains(T item) { throw new NotImplementedException(); @@ -43,9 +76,23 @@ public bool Remove(T item) return false; } - public int Count => throw new NotImplementedException(); + void ICollection.CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } + + public int Count { get; } = count; + + bool ICollection.IsSynchronized => _isSynchronized; + + object ICollection.SyncRoot => throw new NotImplementedException(); public bool IsReadOnly => false; + object? IList.this[int index] + { + get => this[index]; + set => this[index] = (T?)value ?? throw new ArgumentNullException(nameof(value)); + } public int IndexOf(T item) { @@ -62,6 +109,8 @@ public void RemoveAt(int index) Remove(getAt(index)); } + bool IList.IsFixedSize => _isFixedSize; + public T this[int index] { get => getAt(index); @@ -71,4 +120,4 @@ public T this[int index] Insert(index, value); } } -} \ No newline at end of file +} diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs index 83239bc88..d4690ffd5 100644 --- a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs @@ -1,5 +1,6 @@ using MiniLcm; using SIL.LCModel; +using SIL.LCModel.DomainServices; namespace FwDataMiniLcmBridge.Api.UpdateProxy; @@ -26,12 +27,42 @@ public override MultiString Gloss public override string PartOfSpeech { get => throw new NotImplementedException(); - set => throw new NotImplementedException(); + set {} } - public override IList SemanticDomain + public override Guid? PartOfSpeechId { get => throw new NotImplementedException(); + set + { + if (value.HasValue) + { + var partOfSpeech = sense.Cache.ServiceLocator.GetInstance() + .GetObject(value.Value); + if (sense.MorphoSyntaxAnalysisRA == null) + { + sense.SandboxMSA = SandboxGenericMSA.Create(sense.GetDesiredMsaType(), partOfSpeech); + } + else + { + sense.MorphoSyntaxAnalysisRA.SetMsaPartOfSpeech(partOfSpeech); + } + } + else + { + sense.MorphoSyntaxAnalysisRA = null; + } + } + } + + public override IList SemanticDomains + { + get => new UpdateListProxy( + semanticDomain => sense.SemanticDomainsRC.Add(lexboxLcmApi.GetLcmSemanticDomain(semanticDomain)), + semanticDomain => sense.SemanticDomainsRC.Remove(sense.SemanticDomainsRC.First(sd => sd.Guid == semanticDomain.Id)), + i => new SemanticDomain { Id = sense.SemanticDomainsRC.ElementAt(i).Guid, Code = "", Name = new MultiString() }, + sense.SemanticDomainsRC.Count + ); set => throw new NotImplementedException(); } @@ -41,8 +72,9 @@ public override IList ExampleSentences new UpdateListProxy( sentence => lexboxLcmApi.CreateExampleSentence(sense, sentence), sentence => lexboxLcmApi.DeleteExampleSentence(sense.Owner.Guid, Id, sentence.Id), - i => new UpdateExampleSentenceProxy(sense.ExamplesOS[i], lexboxLcmApi) + i => new UpdateExampleSentenceProxy(sense.ExamplesOS[i], lexboxLcmApi), + sense.ExamplesOS.Count ); set => throw new NotImplementedException(); } -} \ No newline at end of file +} diff --git a/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs b/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs index 59d8de9ac..165ff881b 100644 --- a/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs +++ b/backend/FwDataMiniLcmBridge/FwDataBridgeKernel.cs @@ -10,7 +10,9 @@ public static class FwDataBridgeKernel public static IServiceCollection AddFwDataBridge(this IServiceCollection services) { services.AddMemoryCache(); + services.AddLogging(); services.AddSingleton(); + services.AddSingleton(); //todo since this is scoped it gets created on each request (or hub method call), which opens the project file on each request //this is not ideal since opening the project file can be slow. It should be done once per hub connection. services.AddKeyedScoped(FwDataApiKey, (provider, o) => provider.GetRequiredService().GetCurrentFwDataMiniLcmApi(true)); diff --git a/backend/FwDataMiniLcmBridge/FwDataFactory.cs b/backend/FwDataMiniLcmBridge/FwDataFactory.cs index 16497de43..80b872a8f 100644 --- a/backend/FwDataMiniLcmBridge/FwDataFactory.cs +++ b/backend/FwDataMiniLcmBridge/FwDataFactory.cs @@ -6,7 +6,12 @@ namespace FwDataMiniLcmBridge; -public class FwDataFactory(FwDataProjectContext context, ILogger fwdataLogger, IMemoryCache cache, ILogger logger): IDisposable +public class FwDataFactory( + FwDataProjectContext context, + ILogger fwdataLogger, + IMemoryCache cache, + ILogger logger, + IProjectLoader projectLoader) : IDisposable { public FwDataMiniLcmApi GetFwDataMiniLcmApi(string projectName, bool saveOnDispose) { @@ -32,7 +37,7 @@ private LcmCache GetProjectServiceCached(FwDataProject project) entry.SlidingExpiration = TimeSpan.FromMinutes(30); entry.RegisterPostEvictionCallback(OnLcmProjectCacheEviction, (logger, _projects)); logger.LogInformation("Loading project {ProjectFileName}", project.FileName); - var projectService = ProjectLoader.LoadCache(project.FileName); + var projectService = projectLoader.LoadCache(project.FileName); logger.LogInformation("Project {ProjectFileName} loaded", project.FileName); _projects.Add((string)entry.Key); return projectService; @@ -69,7 +74,7 @@ public void Dispose() foreach (var project in _projects) { var lcmCache = cache.Get(project); - if (lcmCache is null) continue; + if (lcmCache is null || lcmCache.IsDisposed) continue; var name = lcmCache.ProjectId.Name; lcmCache.Dispose();//need to explicitly call dispose as that blocks, just removing from the cache does not block, meaning it will not finish disposing before the program exits. logger.LogInformation("FW Data Project {ProjectFileName} disposed", name); diff --git a/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj b/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj index f7b18e4ae..a9491e01c 100644 --- a/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj +++ b/backend/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj @@ -8,6 +8,7 @@ + @@ -19,6 +20,9 @@ + + + diff --git a/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs b/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs index 9ab99f34b..5ca025447 100644 --- a/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs +++ b/backend/FwDataMiniLcmBridge/LcmUtils/ProjectLoader.cs @@ -4,7 +4,17 @@ namespace FwDataMiniLcmBridge.LcmUtils; -public class ProjectLoader +public interface IProjectLoader +{ + /// + /// loads a fwdata file that lives in the project folder C:\ProgramData\SIL\FieldWorks\Projects + /// + /// could be the full path or just the file name, the path will be ignored, must include the extension + /// + LcmCache LoadCache(string fileName); +} + +public class ProjectLoader : IProjectLoader { public const string ProjectFolder = @"C:\ProgramData\SIL\FieldWorks\Projects"; private static string TemplatesFolder { get; } = @"C:\ProgramData\SIL\FieldWorks\Templates"; @@ -29,7 +39,7 @@ public static void Init() /// /// could be the full path or just the file name, the path will be ignored, must include the extension /// - public static LcmCache LoadCache(string fileName) + public LcmCache LoadCache(string fileName) { Init(); fileName = Path.GetFileName(fileName); diff --git a/backend/LcmCrdt.Tests/Changes/JsonPatchChangeTests.cs b/backend/LcmCrdt.Tests/Changes/JsonPatchChangeTests.cs new file mode 100644 index 000000000..80a00aff9 --- /dev/null +++ b/backend/LcmCrdt.Tests/Changes/JsonPatchChangeTests.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using Crdt.Entities; +using LcmCrdt.Changes; +using LcmCrdt.Objects; +using SystemTextJsonPatch; + +namespace LcmCrdt.Tests.Changes; + +public class JsonPatchChangeTests +{ + [Fact] + public void NewChangeAction_ThrowsForRemoveAtIndex() + { + var act = () => new JsonPatchChange(Guid.NewGuid(), + patch => + { + patch.Remove(entry => entry.Senses, 1); + }); + act.Should().Throw(); + } + + [Fact] + public void NewChangeDirect_ThrowsForRemoveAtIndex() + { + var patch = new JsonPatchDocument(); + patch.Remove(entry => entry.Senses, 1); + var act = () => new JsonPatchChange(Guid.NewGuid(), patch); + act.Should().Throw(); + } + + [Fact] + public void NewChangeIPatchDoc_ThrowsForRemoveAtIndex() + { + var patch = new JsonPatchDocument(); + patch.Remove(entry => entry.Senses, 1); + var act = () => new JsonPatchChange(Guid.NewGuid(), patch, JsonSerializerOptions.Default); + act.Should().Throw(); + } + + [Fact] + public void NewPatchDoc_ThrowsForIndexBasedPath() + { + var patch = new JsonPatchDocument(); + patch.Replace(entry => entry.Senses[0].PartOfSpeech, "noun"); + var act = () => new JsonPatchChange(Guid.NewGuid(), patch); + act.Should().Throw(); + } +} diff --git a/backend/LcmCrdt.Tests/JsonPatchRewriteTests.cs b/backend/LcmCrdt.Tests/JsonPatchRewriteTests.cs new file mode 100644 index 000000000..667a47453 --- /dev/null +++ b/backend/LcmCrdt.Tests/JsonPatchRewriteTests.cs @@ -0,0 +1,129 @@ +using LcmCrdt.Changes; +using MiniLcm; +using SystemTextJsonPatch; +using SemanticDomain = LcmCrdt.Objects.SemanticDomain; +using Sense = LcmCrdt.Objects.Sense; + +namespace LcmCrdt.Tests; + +public class JsonPatchRewriteTests +{ + private Sense _sense = new Sense() + { + Id = Guid.NewGuid(), + EntryId = Guid.NewGuid(), + PartOfSpeechId = Guid.NewGuid(), + PartOfSpeech = "test", + SemanticDomains = [new SemanticDomain() { Id = Guid.NewGuid(), Code = "test", Name = new MultiString() }], + }; + + [Fact] + public void RewritePartOfSpeechChangesIntoSetPartOfSpeechChange() + { + var newPartOfSpeechId = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(s => s.PartOfSpeechId, newPartOfSpeechId); + patchDocument.Replace(s => s.Gloss["en"], "new gloss"); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var setPartOfSpeechChange = changes.OfType().Should().ContainSingle().Subject; + setPartOfSpeechChange.EntityId.Should().Be(_sense.Id); + setPartOfSpeechChange.PartOfSpeechId.Should().Be(newPartOfSpeechId); + + var patchChange = changes.OfType>().Should().ContainSingle().Subject; + patchChange.EntityId.Should().Be(_sense.Id); + patchChange.PatchDocument.Operations.Should().ContainSingle().Subject.Value.Should().Be("new gloss"); + } + + [Fact] + public void JsonPatchChangeRewriteDoesNotReturnEmptyPatchChanges() + { + var newPartOfSpeechId = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(s => s.PartOfSpeechId, newPartOfSpeechId); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var setPartOfSpeechChange = changes.Should().ContainSingle() + .Subject.Should().BeOfType().Subject; + setPartOfSpeechChange.EntityId.Should().Be(_sense.Id); + setPartOfSpeechChange.PartOfSpeechId.Should().Be(newPartOfSpeechId); + } + + [Fact] + public void RewritesAddSemanticDomainChangesIntoAddSemanticDomainChange() + { + var newSemanticDomainId = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Add(s => s.SemanticDomains, + new SemanticDomain() { Id = newSemanticDomainId, Code = "new code", Name = new MultiString() }); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var addSemanticDomainChange = (AddSemanticDomainChange)changes.Should().AllBeOfType().And.ContainSingle().Subject; + addSemanticDomainChange.EntityId.Should().Be(_sense.Id); + addSemanticDomainChange.SemanticDomain.Id.Should().Be(newSemanticDomainId); + } + + [Fact] + public void RewritesReplaceSemanticDomainPatchChangesIntoReplaceSemanticDomainChange() + { + var oldSemanticDomainId = _sense.SemanticDomains[0].Id; + var newSemanticDomainId = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(s => s.SemanticDomains, new SemanticDomain() { Id = newSemanticDomainId, Code = "new code", Name = new MultiString() }, 0); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var replaceSemanticDomainChange = (ReplaceSemanticDomainChange)changes.Should().AllBeOfType().And.ContainSingle().Subject; + replaceSemanticDomainChange.EntityId.Should().Be(_sense.Id); + replaceSemanticDomainChange.SemanticDomain.Id.Should().Be(newSemanticDomainId); + replaceSemanticDomainChange.OldSemanticDomainId.Should().Be(oldSemanticDomainId); + } + [Fact] + public void RewritesReplaceNoIndexSemanticDomainPatchChangesIntoReplaceSemanticDomainChange() + { + var oldSemanticDomainId = _sense.SemanticDomains[0].Id; + var newSemanticDomainId = Guid.NewGuid(); + var patchDocument = new JsonPatchDocument(); + patchDocument.Replace(s => s.SemanticDomains, new SemanticDomain() { Id = newSemanticDomainId, Code = "new code", Name = new MultiString() }); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var replaceSemanticDomainChange = (ReplaceSemanticDomainChange)changes.Should().AllBeOfType().And.ContainSingle().Subject; + replaceSemanticDomainChange.EntityId.Should().Be(_sense.Id); + replaceSemanticDomainChange.SemanticDomain.Id.Should().Be(newSemanticDomainId); + replaceSemanticDomainChange.OldSemanticDomainId.Should().Be(oldSemanticDomainId); + } + + [Fact] + public void RewritesRemoveSemanticDomainPatchChangesIntoReplaceSemanticDomainChange() + { + var patchDocument = new JsonPatchDocument(); + var semanticDomainIdToRemove = _sense.SemanticDomains[0].Id; + patchDocument.Remove(s => s.SemanticDomains, 0); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var removeSemanticDomainChange = (RemoveSemanticDomainChange)changes.Should().AllBeOfType< + RemoveSemanticDomainChange>().And.ContainSingle().Subject; + removeSemanticDomainChange.EntityId.Should().Be(_sense.Id); + removeSemanticDomainChange.SemanticDomainId.Should().Be(semanticDomainIdToRemove); + } + + [Fact] + public void RewritesRemoveNoIndexSemanticDomainPatchChangesIntoReplaceSemanticDomainChange() + { + var patchDocument = new JsonPatchDocument(); + var semanticDomainIdToRemove = _sense.SemanticDomains[0].Id; + patchDocument.Remove(s => s.SemanticDomains); + + var changes = Sense.ChangesFromJsonPatch(_sense, patchDocument).ToArray(); + + var removeSemanticDomainChange = (RemoveSemanticDomainChange)changes.Should().AllBeOfType< + RemoveSemanticDomainChange>().And.ContainSingle().Subject; + removeSemanticDomainChange.EntityId.Should().Be(_sense.Id); + removeSemanticDomainChange.SemanticDomainId.Should().Be(semanticDomainIdToRemove); + } +} diff --git a/backend/LcmCrdt.Tests/LexboxApiTests.cs b/backend/LcmCrdt.Tests/LexboxApiTests.cs index 8f1f862f9..69eb7c05e 100644 --- a/backend/LcmCrdt.Tests/LexboxApiTests.cs +++ b/backend/LcmCrdt.Tests/LexboxApiTests.cs @@ -1,5 +1,6 @@ using Crdt; using Crdt.Db; +using LcmCrdt.Changes; using LcmCrdt.Tests.Mocks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -336,9 +337,66 @@ public async Task UpdateSense() updatedSense.Definition.Values["en"].Should().Be("updated"); } + [Fact] + public async Task CreateSense_WontCreateMissingDomains() + { + var senseId = Guid.NewGuid(); + var createdSense = await _api.CreateSense(_entry1Id, new Sense() + { + Id = senseId, + SemanticDomains = [new SemanticDomain() { Id = Guid.NewGuid(), Code = "test", Name = new MultiString() }], + }); + createdSense.Id.Should().Be(senseId); + createdSense.SemanticDomains.Should().BeEmpty("because the domain does not exist (or was deleted)"); + } + + + [Fact] + public async Task CreateSense_WillCreateWithExistingDomains() + { + var senseId = Guid.NewGuid(); + var semanticDomainId = Guid.NewGuid(); + await DataModel.AddChange(Guid.NewGuid(), new CreateSemanticDomainChange(semanticDomainId, new MultiString() { { "en", "test" } }, "test")); + var semanticDomain = await DataModel.GetLatest(semanticDomainId); + ArgumentNullException.ThrowIfNull(semanticDomain); + var createdSense = await _api.CreateSense(_entry1Id, new Sense() + { + Id = senseId, + SemanticDomains = [semanticDomain], + }); + createdSense.Id.Should().Be(senseId); + createdSense.SemanticDomains.Should().ContainSingle(s => s.Id == semanticDomainId); + } + + [Fact] + public async Task CreateSense_WontCreateMissingPartOfSpeech() + { + var senseId = Guid.NewGuid(); + var createdSense = await _api.CreateSense(_entry1Id, + new Sense() { Id = senseId, PartOfSpeech = "test", PartOfSpeechId = Guid.NewGuid(), }); + createdSense.Id.Should().Be(senseId); + createdSense.PartOfSpeechId.Should().BeNull("because the part of speech does not exist (or was deleted)"); + } + + [Fact] + public async Task CreateSense_WillCreateWthExistingPartOfSpeech() + { + var senseId = Guid.NewGuid(); + var partOfSpeechId = Guid.NewGuid(); + await DataModel.AddChange(Guid.NewGuid(), new CreatePartOfSpeechChange(partOfSpeechId, new MultiString() { { "en", "test" } })); + var partOfSpeech = await DataModel.GetLatest(partOfSpeechId); + ArgumentNullException.ThrowIfNull(partOfSpeech); + var createdSense = await _api.CreateSense(_entry1Id, + new Sense() { Id = senseId, PartOfSpeech = "test", PartOfSpeechId = partOfSpeechId, }); + createdSense.Id.Should().Be(senseId); + createdSense.PartOfSpeechId.Should().Be(partOfSpeechId, "because the part of speech does exist"); + } + [Fact] public async Task UpdateSensePartOfSpeech() { + var partOfSpeechId = Guid.NewGuid(); + await DataModel.AddChange(Guid.NewGuid(), new CreatePartOfSpeechChange(partOfSpeechId, new MultiString() { { "en", "Adverb" } })); var entry = await _api.CreateEntry(new Entry { LexemeForm = new MultiString @@ -367,13 +425,19 @@ public async Task UpdateSensePartOfSpeech() entry.Senses[0].Id, _api.CreateUpdateBuilder() .Set(e => e.PartOfSpeech, "updated") + .Set(e => e.PartOfSpeechId, partOfSpeechId) .Build()); updatedSense.PartOfSpeech.Should().Be("updated"); + updatedSense.PartOfSpeechId.Should().Be(partOfSpeechId); } [Fact] public async Task UpdateSenseSemanticDomain() { + var newDomainId = Guid.NewGuid(); + await DataModel.AddChange(Guid.NewGuid(), new CreateSemanticDomainChange(newDomainId, new MultiString() { { "en", "test" } }, "updated")); + var newSemanticDomain = await DataModel.GetLatest(newDomainId); + ArgumentNullException.ThrowIfNull(newSemanticDomain); var entry = await _api.CreateEntry(new Entry { LexemeForm = new MultiString @@ -387,10 +451,7 @@ public async Task UpdateSenseSemanticDomain() { new Sense() { - SemanticDomain = - [ - "test" - ], + SemanticDomains = [new SemanticDomain() { Id = Guid.Empty, Code = "test", Name = new MultiString() }], Definition = new MultiString { Values = @@ -404,9 +465,11 @@ public async Task UpdateSenseSemanticDomain() var updatedSense = await _api.UpdateSense(entry.Id, entry.Senses[0].Id, _api.CreateUpdateBuilder() - .Set(e => e.SemanticDomain[0], "updated") + .Add(e => e.SemanticDomains, newSemanticDomain) .Build()); - updatedSense.SemanticDomain.Should().Contain("updated"); + var semanticDomain = updatedSense.SemanticDomains.Should().ContainSingle(s => s.Id == newDomainId).Subject; + semanticDomain.Code.Should().Be("updated"); + semanticDomain.Id.Should().Be(newDomainId); } [Fact] diff --git a/backend/LcmCrdt/Changes/AddSemanticDomainChange.cs b/backend/LcmCrdt/Changes/AddSemanticDomainChange.cs new file mode 100644 index 000000000..8f65f9dbf --- /dev/null +++ b/backend/LcmCrdt/Changes/AddSemanticDomainChange.cs @@ -0,0 +1,24 @@ +using Crdt.Changes; +using Crdt.Entities; +using MiniLcm; + +namespace LcmCrdt.Changes; + +public class AddSemanticDomainChange(SemanticDomain semanticDomain, Guid senseId) + : EditChange(senseId), ISelfNamedType +{ + public SemanticDomain SemanticDomain { get; } = semanticDomain; + + public override async ValueTask ApplyChange(Sense entity, ChangeContext context) + { + if (await context.IsObjectDeleted(SemanticDomain.Id)) + { + //do nothing, don't add the domain if it's already deleted + } + else if (entity.SemanticDomains.All(s => s.Id != SemanticDomain.Id)) + { + //only add the domain if it's not already in the list + entity.SemanticDomains = [..entity.SemanticDomains, SemanticDomain]; + } + } +} diff --git a/backend/LcmCrdt/Changes/CreatePartOfSpeechChange.cs b/backend/LcmCrdt/Changes/CreatePartOfSpeechChange.cs new file mode 100644 index 000000000..84cd48eda --- /dev/null +++ b/backend/LcmCrdt/Changes/CreatePartOfSpeechChange.cs @@ -0,0 +1,19 @@ +using Crdt; +using Crdt.Changes; +using Crdt.Entities; +using MiniLcm; +using PartOfSpeech = LcmCrdt.Objects.PartOfSpeech; + +namespace LcmCrdt.Changes; + +public class CreatePartOfSpeechChange(Guid entityId, MultiString name, bool predefined = false) + : CreateChange(entityId), ISelfNamedType +{ + public MultiString Name { get; } = name; + public bool Predefined { get; } = predefined; + + public override async ValueTask NewEntity(Commit commit, ChangeContext context) + { + return new PartOfSpeech { Id = EntityId, Name = Name, Predefined = Predefined }; + } +} diff --git a/backend/LcmCrdt/Changes/CreateSemanticDomainChange.cs b/backend/LcmCrdt/Changes/CreateSemanticDomainChange.cs new file mode 100644 index 000000000..dabac0c13 --- /dev/null +++ b/backend/LcmCrdt/Changes/CreateSemanticDomainChange.cs @@ -0,0 +1,20 @@ +using Crdt; +using Crdt.Changes; +using Crdt.Entities; +using MiniLcm; +using SemanticDomain = LcmCrdt.Objects.SemanticDomain; + +namespace LcmCrdt.Changes; + +public class CreateSemanticDomainChange(Guid semanticDomainId, MultiString name, string code, bool predefined = false) + : CreateChange(semanticDomainId), ISelfNamedType +{ + public MultiString Name { get; } = name; + public bool Predefined { get; } = predefined; + public string Code { get; } = code; + + public override async ValueTask NewEntity(Commit commit, ChangeContext context) + { + return new SemanticDomain { Id = EntityId, Code = Code, Name = Name, Predefined = Predefined }; + } +} diff --git a/backend/LcmCrdt/Changes/CreateSenseChange.cs b/backend/LcmCrdt/Changes/CreateSenseChange.cs index d5dcffde5..206cf425f 100644 --- a/backend/LcmCrdt/Changes/CreateSenseChange.cs +++ b/backend/LcmCrdt/Changes/CreateSenseChange.cs @@ -14,9 +14,10 @@ public CreateSenseChange(MiniLcm.Sense sense, Guid entryId) : base(sense.Id == G sense.Id = EntityId; EntryId = entryId; Definition = sense.Definition; - SemanticDomain = sense.SemanticDomain; + SemanticDomains = sense.SemanticDomains; Gloss = sense.Gloss; PartOfSpeech = sense.PartOfSpeech; + PartOfSpeechId = sense.PartOfSpeechId; } [JsonConstructor] @@ -29,7 +30,8 @@ private CreateSenseChange(Guid entityId, Guid entryId) : base(entityId) public MultiString? Definition { get; set; } public MultiString? Gloss { get; set; } public string? PartOfSpeech { get; set; } - public IList? SemanticDomain { get; set; } + public Guid? PartOfSpeechId { get; set; } + public IList? SemanticDomains { get; set; } public override async ValueTask NewEntity(Commit commit, ChangeContext context) { @@ -40,7 +42,8 @@ public override async ValueTask NewEntity(Commit commit, ChangeCont Definition = Definition ?? new MultiString(), Gloss = Gloss ?? new MultiString(), PartOfSpeech = PartOfSpeech ?? string.Empty, - SemanticDomain = SemanticDomain ?? [], + PartOfSpeechId = PartOfSpeechId, + SemanticDomains = SemanticDomains ?? [], DeletedAt = await context.IsObjectDeleted(EntryId) ? commit.DateTime : (DateTime?)null }; } diff --git a/backend/LcmCrdt/Changes/JsonPatchChange.cs b/backend/LcmCrdt/Changes/JsonPatchChange.cs index 393b4be56..5c044e227 100644 --- a/backend/LcmCrdt/Changes/JsonPatchChange.cs +++ b/backend/LcmCrdt/Changes/JsonPatchChange.cs @@ -1,10 +1,12 @@ -using System.Text.Json; +using System.Buffers; +using System.Text.Json; using System.Text.Json.Serialization; using Crdt; using Crdt.Changes; using Crdt.Db; using Crdt.Entities; using SystemTextJsonPatch; +using SystemTextJsonPatch.Internal; using SystemTextJsonPatch.Operations; namespace LcmCrdt.Changes; @@ -16,19 +18,23 @@ public JsonPatchChange(Guid entityId, Action> action) : bas { PatchDocument = new(); action(PatchDocument); + JsonPatchValidator.ValidatePatchDocument(PatchDocument); } [JsonConstructor] public JsonPatchChange(Guid entityId, JsonPatchDocument patchDocument): base(entityId) { PatchDocument = patchDocument; + JsonPatchValidator.ValidatePatchDocument(PatchDocument); } public JsonPatchChange(Guid entityId, IJsonPatchDocument patchDocument, JsonSerializerOptions options): base(entityId) { PatchDocument = new JsonPatchDocument(patchDocument.GetOperations().Select(o => new Operation(o.Op!, o.Path!, o.From, o.Value)).ToList(), options); + JsonPatchValidator.ValidatePatchDocument(PatchDocument); } + public JsonPatchDocument PatchDocument { get; } public override ValueTask ApplyChange(T entity, ChangeContext context) @@ -37,3 +43,28 @@ public override ValueTask ApplyChange(T entity, ChangeContext context) return ValueTask.CompletedTask; } } + +file static class JsonPatchValidator +{ + + /// + /// prevents the use of indexes in the path, as this will cause major problems with CRDTs. + /// + public static void ValidatePatchDocument(IJsonPatchDocument patchDocument) + { + foreach (var operation in patchDocument.GetOperations()) + { + if (operation.OperationType == OperationType.Remove) + { + throw new NotSupportedException("remove at index not supported"); + } + + // we want to make sure that the path is not an index, as a shortcut we just check the first character is not a digit, because it's invalid for fields to start with a digit. + //however this could be overriden with a json path name + if (new ParsedPath(operation.Path).Segments.Any(s => char.IsDigit(s[0]))) + { + throw new NotSupportedException($"no path operation can be made with an index, path: {operation.Path}"); + } + } + } +} diff --git a/backend/LcmCrdt/Changes/RemoveSemanticDomainChange.cs b/backend/LcmCrdt/Changes/RemoveSemanticDomainChange.cs new file mode 100644 index 000000000..fcab2c956 --- /dev/null +++ b/backend/LcmCrdt/Changes/RemoveSemanticDomainChange.cs @@ -0,0 +1,15 @@ +using Crdt.Changes; +using Crdt.Entities; + +namespace LcmCrdt.Changes; + +public class RemoveSemanticDomainChange(Guid semanticDomainId, Guid senseId) + : EditChange(senseId), ISelfNamedType +{ + public Guid SemanticDomainId { get; } = semanticDomainId; + + public override async ValueTask ApplyChange(Sense entity, ChangeContext context) + { + entity.SemanticDomains = [..entity.SemanticDomains.Where(s => s.Id != SemanticDomainId)]; + } +} diff --git a/backend/LcmCrdt/Changes/ReplaceSemanticDomainChange.cs b/backend/LcmCrdt/Changes/ReplaceSemanticDomainChange.cs new file mode 100644 index 000000000..8123a96f9 --- /dev/null +++ b/backend/LcmCrdt/Changes/ReplaceSemanticDomainChange.cs @@ -0,0 +1,27 @@ +using Crdt.Changes; +using Crdt.Entities; +using MiniLcm; + +namespace LcmCrdt.Changes; + +public class ReplaceSemanticDomainChange(Guid oldSemanticDomainId, SemanticDomain semanticDomain, Guid senseId) + : EditChange(senseId), ISelfNamedType +{ + public Guid OldSemanticDomainId { get; } = oldSemanticDomainId; + public SemanticDomain SemanticDomain { get; } = semanticDomain; + + public override async ValueTask ApplyChange(Sense entity, ChangeContext context) + { + //remove the old domain + entity.SemanticDomains = [..entity.SemanticDomains.Where(s => s.Id != OldSemanticDomainId)]; + if (await context.IsObjectDeleted(SemanticDomain.Id)) + { + //do nothing, don't add the domain if it's already deleted + } + else if (entity.SemanticDomains.All(s => s.Id != SemanticDomain.Id)) + { + //only add if it's not already in the list + entity.SemanticDomains = [..entity.SemanticDomains, SemanticDomain]; + } + } +} diff --git a/backend/LcmCrdt/Changes/SetPartOfSpeechChange.cs b/backend/LcmCrdt/Changes/SetPartOfSpeechChange.cs new file mode 100644 index 000000000..59922adf7 --- /dev/null +++ b/backend/LcmCrdt/Changes/SetPartOfSpeechChange.cs @@ -0,0 +1,19 @@ +using Crdt.Changes; +using Crdt.Entities; + +namespace LcmCrdt.Changes; + +public class SetPartOfSpeechChange(Guid entityId, Guid? partOfSpeechId) : EditChange(entityId), ISelfNamedType +{ + public Guid? PartOfSpeechId { get; } = partOfSpeechId; + + public override async ValueTask ApplyChange(Sense entity, ChangeContext context) + { + entity.PartOfSpeechId = PartOfSpeechId switch + { + null => null, + var id when await context.IsObjectDeleted(id.Value) => null, + _ => PartOfSpeechId + }; + } +} diff --git a/backend/LcmCrdt/CrdtLexboxApi.cs b/backend/LcmCrdt/CrdtLexboxApi.cs index 18b5d846a..36889e2bd 100644 --- a/backend/LcmCrdt/CrdtLexboxApi.cs +++ b/backend/LcmCrdt/CrdtLexboxApi.cs @@ -215,8 +215,9 @@ await dataModel.AddChanges(ClientId, Guid senseId, UpdateObjectInput update) { - var patchChange = new JsonPatchChange(senseId, update.Patch, jsonOptions); - await dataModel.AddChange(ClientId, patchChange); + var sense = await dataModel.GetLatest(senseId); + if (sense is null) throw new NullReferenceException($"unable to find sense with id {senseId}"); + await dataModel.AddChanges(ClientId, [..Sense.ChangesFromJsonPatch(sense, update.Patch)]); return await dataModel.GetLatest(senseId) ?? throw new NullReferenceException(); } @@ -253,4 +254,5 @@ public UpdateBuilder CreateUpdateBuilder() where T : class { return new UpdateBuilder(); } + } diff --git a/backend/LcmCrdt/LcmCrdtKernel.cs b/backend/LcmCrdt/LcmCrdtKernel.cs index c143ce732..d8b5e670c 100644 --- a/backend/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/LcmCrdt/LcmCrdtKernel.cs @@ -4,7 +4,7 @@ using Crdt; using Crdt.Changes; using LcmCrdt.Changes; -using MiniLcm; +using LcmCrdt.Objects; using LinqToDB; using LinqToDB.AspNet.Logging; using LinqToDB.Data; @@ -17,9 +17,6 @@ namespace LcmCrdt; -using Entry = Objects.Entry; -using ExampleSentence = Objects.ExampleSentence; -using Sense = Objects.Sense; public static class LcmCrdtKernel { @@ -32,7 +29,7 @@ public static IServiceCollection AddLcmCrdtClient(this IServiceCollection servic ConfigureDbOptions, ConfigureCrdt ); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); @@ -52,7 +49,7 @@ private static void ConfigureDbOptions(IServiceProvider provider, DbContextOptio .HasAttribute(new ColumnAttribute(nameof(HybridDateTime.Counter), nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) .Build(); - mappingSchema.SetConvertExpression((WritingSystemId id) => + mappingSchema.SetConvertExpression((MiniLcm.WritingSystemId id) => new DataParameter { Value = id.Code, DataType = DataType.Text }); optionsBuilder.AddMappingSchema(mappingSchema); var loggerFactory = provider.GetService(); @@ -71,10 +68,10 @@ private static void ConfigureCrdt(CrdtConfig config) }) .AddDbModelConvention(builder => { - builder.Properties() + builder.Properties() .HaveColumnType("jsonb") .HaveConversion(); - builder.Properties() + builder.Properties() .HaveConversion(); }) .Add(builder => @@ -89,10 +86,10 @@ private static void ConfigureCrdt(CrdtConfig config) builder.HasOne() .WithMany() .HasForeignKey(sense => sense.EntryId); - builder.Property(s => s.SemanticDomain) + builder.Property(s => s.SemanticDomains) .HasColumnType("jsonb") .HasConversion(list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), - json => JsonSerializer.Deserialize>(json, (JsonSerializerOptions?)null) ?? new()); + json => JsonSerializer.Deserialize>(json, (JsonSerializerOptions?)null) ?? new()); }) .Add(builder => { @@ -107,28 +104,36 @@ private static void ConfigureCrdt(CrdtConfig config) .HasConversion(list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null) ?? Array.Empty()); - }); + }).Add().Add(); config.ChangeTypeListBuilder.Add>() .Add>() .Add>() .Add>() + .Add>() + .Add>() .Add>() .Add>() .Add>() .Add>() + .Add>() + .Add>() + .Add() + .Add() .Add() .Add() .Add() + .Add() + .Add() .Add(); } - private class MultiStringDbConverter() : ValueConverter( + private class MultiStringDbConverter() : ValueConverter( mul => JsonSerializer.Serialize(mul, (JsonSerializerOptions?)null), - json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null) ?? new()); + json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null) ?? new()); - private class WritingSystemIdConverter() : ValueConverter( + private class WritingSystemIdConverter() : ValueConverter( id => id.Code, - code => new WritingSystemId(code)); + code => new MiniLcm.WritingSystemId(code)); } diff --git a/backend/LcmCrdt/Objects/PartOfSpeech.cs b/backend/LcmCrdt/Objects/PartOfSpeech.cs new file mode 100644 index 000000000..997c2c420 --- /dev/null +++ b/backend/LcmCrdt/Objects/PartOfSpeech.cs @@ -0,0 +1,46 @@ +using Crdt; +using Crdt.Entities; +using LcmCrdt.Changes; +using MiniLcm; + +namespace LcmCrdt.Objects; + +public class PartOfSpeech : MiniLcm.PartOfSpeech, IObjectBase +{ + Guid IObjectBase.Id + { + get => Id; + init => Id = value; + } + public DateTimeOffset? DeletedAt { get; set; } + public bool Predefined { get; set; } + public Guid[] GetReferences() + { + return []; + } + + public void RemoveReference(Guid id, Commit commit) + { + } + + public IObjectBase Copy() + { + return new PartOfSpeech + { + Id = Id, + Name = Name, + DeletedAt = DeletedAt, + Predefined = Predefined + }; + } + + public static async Task PredefinedPartsOfSpeech(DataModel dataModel, Guid clientId) + { + //todo load from xml instead of hardcoding + await dataModel.AddChanges(clientId, + [ + new CreatePartOfSpeechChange(new Guid("46e4fe08-ffa0-4c8b-bf98-2c56f38904d9"), new MultiString() { { "en", "Adverb" } }, true) + ], + new Guid("023faebb-711b-4d2f-b34f-a15621fc66bb")); + } +} diff --git a/backend/LcmCrdt/Objects/SemanticDomain.cs b/backend/LcmCrdt/Objects/SemanticDomain.cs new file mode 100644 index 000000000..8402571bc --- /dev/null +++ b/backend/LcmCrdt/Objects/SemanticDomain.cs @@ -0,0 +1,37 @@ +using Crdt; +using Crdt.Entities; + +namespace LcmCrdt.Objects; + +public class SemanticDomain : MiniLcm.SemanticDomain, IObjectBase +{ + Guid IObjectBase.Id + { + get => Id; + init => Id = value; + } + + public DateTimeOffset? DeletedAt { get; set; } + public bool Predefined { get; set; } + + public Guid[] GetReferences() + { + return []; + } + + public void RemoveReference(Guid id, Commit commit) + { + } + + public IObjectBase Copy() + { + return new SemanticDomain + { + Id = Id, + Code = Code, + Name = Name, + DeletedAt = DeletedAt, + Predefined = Predefined + }; + } +} diff --git a/backend/LcmCrdt/Objects/Sense.cs b/backend/LcmCrdt/Objects/Sense.cs index 4ecd9fd61..d23538d03 100644 --- a/backend/LcmCrdt/Objects/Sense.cs +++ b/backend/LcmCrdt/Objects/Sense.cs @@ -1,11 +1,58 @@ using Crdt; +using Crdt.Changes; using Crdt.Db; using Crdt.Entities; +using LcmCrdt.Changes; +using LcmCrdt.Utils; +using SystemTextJsonPatch; +using SystemTextJsonPatch.Operations; namespace LcmCrdt.Objects; public class Sense : MiniLcm.Sense, IObjectBase { + public static IEnumerable ChangesFromJsonPatch(Sense sense, JsonPatchDocument patch) + { + foreach (var rewriteChange in patch.RewriteChanges(s => s.PartOfSpeechId, + (partOfSpeechId, operationType) => + { + if (operationType == OperationType.Replace) + return new SetPartOfSpeechChange(sense.Id, partOfSpeechId); + throw new NotSupportedException($"operation {operationType} not supported for part of speech"); + })) + { + yield return rewriteChange; + } + + foreach (var rewriteChange in patch.RewriteChanges(s => s.SemanticDomains, + (semanticDomain, index, operationType) => + { + if (operationType is OperationType.Add) + { + ArgumentNullException.ThrowIfNull(semanticDomain); + return new AddSemanticDomainChange(semanticDomain, sense.Id); + } + + if (operationType is OperationType.Replace) + { + ArgumentNullException.ThrowIfNull(semanticDomain); + return new ReplaceSemanticDomainChange(sense.SemanticDomains[index].Id, semanticDomain, sense.Id); + } + if (operationType is OperationType.Remove) + { + return new RemoveSemanticDomainChange(sense.SemanticDomains[index].Id, sense.Id); + } + + throw new NotSupportedException($"operation {operationType} not supported for semantic domains"); + })) + { + yield return rewriteChange; + } + + if (patch.Operations.Count > 0) + yield return new JsonPatchChange(sense.Id, patch, patch.Options); + } + Guid IObjectBase.Id { get => Id; @@ -17,13 +64,17 @@ Guid IObjectBase.Id public Guid[] GetReferences() { - return [EntryId]; + ReadOnlySpan pos = PartOfSpeechId.HasValue ? [PartOfSpeechId.Value] : []; + return [EntryId, ..pos, ..SemanticDomains.Select(sd => sd.Id)]; } public void RemoveReference(Guid id, Commit commit) { if (id == EntryId) DeletedAt = commit.DateTime; + if (id == PartOfSpeechId) + PartOfSpeechId = null; + SemanticDomains = [..SemanticDomains.Where(sd => sd.Id != id)]; } public IObjectBase Copy() @@ -36,7 +87,8 @@ public IObjectBase Copy() Definition = Definition.Copy(), Gloss = Gloss.Copy(), PartOfSpeech = PartOfSpeech, - SemanticDomain = [..SemanticDomain] + PartOfSpeechId = PartOfSpeechId, + SemanticDomains = [..SemanticDomains] }; } } diff --git a/backend/LcmCrdt/ProjectsService.cs b/backend/LcmCrdt/ProjectsService.cs index bf567f84b..315328022 100644 --- a/backend/LcmCrdt/ProjectsService.cs +++ b/backend/LcmCrdt/ProjectsService.cs @@ -1,6 +1,8 @@ -using Crdt.Db; +using Crdt; +using Crdt.Db; using Microsoft.Extensions.DependencyInjection; using MiniLcm; +using PartOfSpeech = LcmCrdt.Objects.PartOfSpeech; namespace LcmCrdt; @@ -37,8 +39,10 @@ public async Task CreateProject(string name, var crdtProject = new CrdtProject(name, sqliteFile); await using var serviceScope = CreateProjectScope(crdtProject); var db = serviceScope.ServiceProvider.GetRequiredService(); - await InitProjectDb(db, new ProjectData(name, id ?? Guid.NewGuid(), ProjectData.GetOriginDomain(domain), Guid.NewGuid())); + var projectData = new ProjectData(name, id ?? Guid.NewGuid(), ProjectData.GetOriginDomain(domain), Guid.NewGuid()); + await InitProjectDb(db, projectData); await serviceScope.ServiceProvider.GetRequiredService().PopulateProjectDataCache(); + await SeedSystemData(serviceScope.ServiceProvider.GetRequiredService(), projectData.ClientId); await (afterCreate?.Invoke(serviceScope.ServiceProvider, crdtProject) ?? Task.CompletedTask); return crdtProject; } @@ -50,6 +54,11 @@ internal static async Task InitProjectDb(CrdtDbContext db, ProjectData data) await db.SaveChangesAsync(); } + internal static async Task SeedSystemData(DataModel dataModel, Guid clientId) + { + await PartOfSpeech.PredefinedPartsOfSpeech(dataModel, clientId); + } + public AsyncServiceScope CreateProjectScope(CrdtProject crdtProject) { var serviceScope = provider.CreateAsyncScope(); diff --git a/backend/LcmCrdt/Utils/JsonPatchRewriter.cs b/backend/LcmCrdt/Utils/JsonPatchRewriter.cs new file mode 100644 index 000000000..3dbe5ba45 --- /dev/null +++ b/backend/LcmCrdt/Utils/JsonPatchRewriter.cs @@ -0,0 +1,157 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Crdt.Changes; +using SystemTextJsonPatch; +using SystemTextJsonPatch.Internal; +using SystemTextJsonPatch.Operations; + +namespace LcmCrdt.Utils; + +public static class JsonPatchRewriter +{ + + public static IEnumerable RewriteChanges(this JsonPatchDocument patchDocument, + Expression> expr, Func changeFactory) where T : class + { + var path = GetPath(expr, null, patchDocument.Options); + foreach (var operation in patchDocument.Operations.ToArray()) + { + if (operation.Path == path) + { + patchDocument.Operations.Remove(operation); + yield return changeFactory((TProp?)operation.Value, operation.OperationType); + } + } + } + public static IEnumerable RewriteChanges(this JsonPatchDocument patchDocument, + Expression>> expr, Func changeFactory) where T : class + { + var path = GetPath(expr, null, patchDocument.Options); + foreach (var operation in patchDocument.Operations.ToArray()) + { + if (operation.Path is null || !operation.Path.StartsWith(path)) continue; + Index index; + if (operation.Path == path) + { + index = default; + } + else + { + var parsedPath = new ParsedPath(operation.Path); + if (parsedPath.LastSegment is "-") + { + index = Index.FromEnd(1); + } + else if (int.TryParse(parsedPath.LastSegment, out var i)) + { + index = Index.FromStart(i); + } + else + { + continue; + } + } + patchDocument.Operations.Remove(operation); + yield return changeFactory((TProp?)operation.Value, index, operation.OperationType); + } + } + + //won't work until dotnet 9 per https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.unsafeaccessorattribute?view=net-8.0#remarks + //this is due to generics, for now we will use the version copied below + private static class JsonPatchAccessors where T : class + { + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetPath")] + public static extern string ExpressionToJsonPointer( + JsonPatchDocument patchDocument, + Expression> expr, + string? position); + } + + + public static string GetPath(Expression> expr, string? position, JsonSerializerOptions options) + { + var segments = GetPathSegments(expr.Body, options); + var path = string.Join("/", segments); + if (position != null) + { + path += "/" + position; + if (segments.Count == 0) + { + return path; + } + } + + return "/" + path; + } + + private static List GetPathSegments(Expression? expr, JsonSerializerOptions options) + { + if (expr == null || expr.NodeType == ExpressionType.Parameter) return []; + var listOfSegments = new List(); + switch (expr.NodeType) + { + case ExpressionType.ArrayIndex: + var binaryExpression = (BinaryExpression)expr; + listOfSegments.AddRange(GetPathSegments(binaryExpression.Left, options)); + listOfSegments.Add(binaryExpression.Right.ToString()); + return listOfSegments; + + case ExpressionType.Call: + var methodCallExpression = (MethodCallExpression)expr; + listOfSegments.AddRange(GetPathSegments(methodCallExpression.Object, options)); + listOfSegments.Add(EvaluateExpression(methodCallExpression.Arguments[0])); + return listOfSegments; + + case ExpressionType.Convert: + listOfSegments.AddRange(GetPathSegments(((UnaryExpression)expr).Operand, options)); + return listOfSegments; + + case ExpressionType.MemberAccess: + var memberExpression = (MemberExpression)expr; + listOfSegments.AddRange(GetPathSegments(memberExpression.Expression, options)); + // Get property name, respecting JsonProperty attribute + listOfSegments.Add(GetPropertyNameFromMemberExpression(memberExpression, options)); + return listOfSegments; + + default: + throw new InvalidOperationException($"type of expression not supported {expr}"); + } + } + + private static string GetPropertyNameFromMemberExpression(MemberExpression memberExpression, JsonSerializerOptions options) + { + var jsonPropertyNameAttr = memberExpression.Member.GetCustomAttribute(); + + if (jsonPropertyNameAttr != null && !string.IsNullOrEmpty(jsonPropertyNameAttr.Name)) + { + return jsonPropertyNameAttr.Name; + } + + var memberName = memberExpression.Member.Name; + + if (options.PropertyNamingPolicy != null) + { + return options.PropertyNamingPolicy.ConvertName(memberName); + } + + return memberName; + } + + + // Evaluates the value of the key or index which may be an int or a string, + // or some other expression type. + // The expression is converted to a delegate and the result of executing the delegate is returned as a string. + private static string EvaluateExpression(Expression expression) + { + var converted = Expression.Convert(expression, typeof(object)); + var fakeParameter = Expression.Parameter(typeof(object), null); + var lambda = Expression.Lambda>(converted, fakeParameter); + var func = lambda.Compile(); + + return Convert.ToString(func(null), CultureInfo.InvariantCulture) ?? ""; + } +} diff --git a/backend/LfClassicData/LfClassicLexboxApi.cs b/backend/LfClassicData/LfClassicLexboxApi.cs index 1fc8050e0..e85cef0cb 100644 --- a/backend/LfClassicData/LfClassicLexboxApi.cs +++ b/backend/LfClassicData/LfClassicLexboxApi.cs @@ -122,7 +122,9 @@ private static Sense ToSense(Entities.Sense sense) Gloss = ToMultiString(sense.Gloss), Definition = ToMultiString(sense.Definition), PartOfSpeech = sense.PartOfSpeech?.Value ?? string.Empty, - SemanticDomain = sense.SemanticDomain?.Values ?? [], + SemanticDomains = (sense.SemanticDomain?.Values ?? []) + .Select(sd => new SemanticDomain { Id = Guid.Empty, Code = sd, Name = new MultiString { { "en", sd } } }) + .ToList(), ExampleSentences = sense.Examples?.OfType().Select(ToExampleSentence).ToList() ?? [], }; } diff --git a/backend/LocalWebApp/Routes/ProjectRoutes.cs b/backend/LocalWebApp/Routes/ProjectRoutes.cs index 00630fc58..790a8bfe2 100644 --- a/backend/LocalWebApp/Routes/ProjectRoutes.cs +++ b/backend/LocalWebApp/Routes/ProjectRoutes.cs @@ -113,7 +113,7 @@ await lexboxApi.CreateEntry(new() { Gloss = { Values = { { "en", "Fruit" } } }, Definition = { Values = { { "en", "fruit with red, yellow, or green skin with a sweet or tart crispy white flesh" } } }, - SemanticDomain = ["Fruit"], + SemanticDomains = [], ExampleSentences = [new() { Sentence = { Values = { { "en", "We ate an apple" } } } }] } ] diff --git a/backend/LocalWebApp/appsettings.json b/backend/LocalWebApp/appsettings.json index afd3250bb..356485704 100644 --- a/backend/LocalWebApp/appsettings.json +++ b/backend/LocalWebApp/appsettings.json @@ -3,7 +3,8 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore.SignalR": "Debug", - "Microsoft.AspNetCore": "Information" + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore": "Warning" } }, "AllowedHosts": "*" diff --git a/backend/MiniLcm/ILexboxApi.cs b/backend/MiniLcm/ILexboxApi.cs index c322557dd..971840434 100644 --- a/backend/MiniLcm/ILexboxApi.cs +++ b/backend/MiniLcm/ILexboxApi.cs @@ -11,6 +11,15 @@ public interface ILexboxApi Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update); + + IAsyncEnumerable GetPartsOfSpeech() + { + throw new NotImplementedException(); + } + IAsyncEnumerable GetSemanticDomains() + { + throw new NotImplementedException(); + } IAsyncEnumerable GetEntries(QueryOptions? options = null); IAsyncEnumerable SearchEntries(string query, QueryOptions? options = null); Task GetEntry(Guid id); @@ -75,4 +84,18 @@ public UpdateBuilder Set(Expression> field, T_Val value _patchDocument.Replace(field, value); return this; } + public UpdateBuilder Add(Expression>> field, T_Val value) + { + _patchDocument.Add(field, value); + return this; + } + + /// + /// Removes an item by index, should not be used with CRDTs. + /// + public UpdateBuilder Remove(Expression>> field, int index) + { + _patchDocument.Remove(field, index); + return this; + } } diff --git a/backend/MiniLcm/MultiString.cs b/backend/MiniLcm/MultiString.cs index 6f7e96e1f..f06c280e0 100644 --- a/backend/MiniLcm/MultiString.cs +++ b/backend/MiniLcm/MultiString.cs @@ -76,6 +76,11 @@ void IDictionary.Add(object key, object? value) } } + public void Add(string key, string value) + { + Values.Add(key, value); + } + void IDictionary.Add(object key, object? value) { ((IDictionary)Values).Add(key, value); diff --git a/backend/MiniLcm/PartOfSpeech.cs b/backend/MiniLcm/PartOfSpeech.cs new file mode 100644 index 000000000..1f37068aa --- /dev/null +++ b/backend/MiniLcm/PartOfSpeech.cs @@ -0,0 +1,7 @@ +namespace MiniLcm; + +public class PartOfSpeech +{ + public Guid Id { get; set; } + public MultiString Name { get; set; } = new(); +} diff --git a/backend/MiniLcm/SemanticDomain.cs b/backend/MiniLcm/SemanticDomain.cs new file mode 100644 index 000000000..96b075b16 --- /dev/null +++ b/backend/MiniLcm/SemanticDomain.cs @@ -0,0 +1,8 @@ +namespace MiniLcm; + +public class SemanticDomain +{ + public required Guid Id { get; set; } + public required MultiString Name { get; set; } + public required string Code { get; set; } +} diff --git a/backend/MiniLcm/Sense.cs b/backend/MiniLcm/Sense.cs index 975bf0bc6..04f24bef7 100644 --- a/backend/MiniLcm/Sense.cs +++ b/backend/MiniLcm/Sense.cs @@ -6,6 +6,7 @@ public class Sense public virtual MultiString Definition { get; set; } = new(); public virtual MultiString Gloss { get; set; } = new(); public virtual string PartOfSpeech { get; set; } = string.Empty; - public virtual IList SemanticDomain { get; set; } = []; + public virtual Guid? PartOfSpeechId { get; set; } + public virtual IList SemanticDomains { get; set; } = []; public virtual IList ExampleSentences { get; set; } = []; } From a74c983cd84a97753c46f129ab033bbb73503f46 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 14 Jun 2024 14:28:26 -0600 Subject: [PATCH 02/38] remove duplicate field, fix overloaded GetEntries function causing build error due to 2 overloads not requiring parameters. --- backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 88775257d..c4529fe70 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -34,8 +34,6 @@ public class FwDataMiniLcmApi(LcmCache cache, bool onCloseSave, ILogger(); - private readonly ICmPossibilityRepository _cmPossibilityRepository = - cache.ServiceLocator.GetInstance(); private readonly IPartOfSpeechRepository _partOfSpeechRepository = cache.ServiceLocator.GetInstance(); private readonly ICmSemanticDomainRepository _semanticDomainRepository = @@ -247,16 +245,13 @@ private MultiString FromLcmMultiString(ITsMultiString multiString) return result; } - public async IAsyncEnumerable GetEntries(QueryOptions? options = null) + public IAsyncEnumerable GetEntries(QueryOptions? options = null) { - await foreach (var entry in GetEntries(null, options)) - { - yield return entry; - } + return GetEntries(null, options); } public async IAsyncEnumerable GetEntries( - Func? predicate = null, QueryOptions? options = null) + Func? predicate, QueryOptions? options = null) { var entries = _entriesRepository.AllInstances(); From e5e339ab8e987cfb3cc26c04bb8bab4785ebaee0 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 14 Jun 2024 15:14:51 -0600 Subject: [PATCH 03/38] add WritingSystemTests.cs, and make the project loader fixture a collection fixture so it's shared by all tests in the project. --- .../Fixtures/ProjectLoaderFixture.cs | 9 ++++++-- .../FwDataMiniLcmBridge.Tests.csproj | 4 ++++ .../PartOfSpeechTests.cs | 15 +++++-------- .../SemanticDomainTests.cs | 3 ++- .../WritingSystemTests.cs | 22 +++++++++++++++++++ 5 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 backend/FwDataMiniLcmBridge.Tests/WritingSystemTests.cs diff --git a/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs b/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs index 164a4d4d9..bc449a22c 100644 --- a/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs +++ b/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs @@ -5,8 +5,9 @@ namespace FwDataMiniLcmBridge.Tests.Fixtures; public class ProjectLoaderFixture : IDisposable { + public const string Name = "ProjectLoaderCollection"; private readonly FwDataFactory _fwDataFactory; - private ServiceProvider _serviceProvider; + private readonly ServiceProvider _serviceProvider; public ProjectLoaderFixture() { @@ -23,7 +24,11 @@ public FwDataMiniLcmApi CreateApi(string projectName) public void Dispose() { - _fwDataFactory.Dispose(); _serviceProvider.Dispose(); } } + +[CollectionDefinition(ProjectLoaderFixture.Name)] +public class ProjectLoaderCollection : ICollectionFixture +{ +} diff --git a/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj index e797a6ec7..9ce1673c5 100644 --- a/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj +++ b/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -27,4 +27,8 @@ + + + + diff --git a/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs b/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs index 54e4e17b1..532d16bf9 100644 --- a/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs +++ b/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs @@ -3,18 +3,13 @@ namespace FwDataMiniLcmBridge.Tests; -public class PartOfSpeechTests: IClassFixture +[Collection(ProjectLoaderFixture.Name)] +public class PartOfSpeechTests(ProjectLoaderFixture fixture) { - ProjectLoaderFixture _fixture; - public PartOfSpeechTests(ProjectLoaderFixture fixture) - { - _fixture = fixture; - } - [Fact] public async Task GetPartsOfSpeech_ReturnsAllPartsOfSpeech() { - var api = _fixture.CreateApi("sena-3"); + var api = fixture.CreateApi("sena-3"); var partOfSpeeches = await api.GetPartsOfSpeech().ToArrayAsync(); partOfSpeeches.Should().AllSatisfy(po => po.Id.Should().NotBe(Guid.Empty)); } @@ -22,7 +17,7 @@ public async Task GetPartsOfSpeech_ReturnsAllPartsOfSpeech() [Fact] public async Task Sense_HasPartOfSpeech() { - var api = _fixture.CreateApi("sena-3"); + var api = fixture.CreateApi("sena-3"); var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => !string.IsNullOrEmpty(s.PartOfSpeech))); var sense = entry.Senses.First(s => !string.IsNullOrEmpty(s.PartOfSpeech)); sense.PartOfSpeech.Should().NotBeNullOrEmpty(); @@ -32,7 +27,7 @@ public async Task Sense_HasPartOfSpeech() [Fact] public async Task Sense_UpdatesPartOfSpeech() { - var api = _fixture.CreateApi("sena-3"); + var api = fixture.CreateApi("sena-3"); var entry = await api.GetEntries().FirstAsync(e => e.Senses.Any(s => !string.IsNullOrEmpty(s.PartOfSpeech))); var sense = entry.Senses.First(s => !string.IsNullOrEmpty(s.PartOfSpeech)); var newPartOfSpeech = await api.GetPartsOfSpeech().FirstAsync(po => po.Id != sense.PartOfSpeechId); diff --git a/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs b/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs index 0320bc642..b0f1945e6 100644 --- a/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs +++ b/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs @@ -3,7 +3,8 @@ namespace FwDataMiniLcmBridge.Tests; -public class SemanticDomainTests(ProjectLoaderFixture fixture) : IClassFixture +[Collection(ProjectLoaderFixture.Name)] +public class SemanticDomainTests(ProjectLoaderFixture fixture) { [Fact] public async Task GetSemanticDomains_ReturnsAllSemanticDomains() diff --git a/backend/FwDataMiniLcmBridge.Tests/WritingSystemTests.cs b/backend/FwDataMiniLcmBridge.Tests/WritingSystemTests.cs new file mode 100644 index 000000000..0d02475ef --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/WritingSystemTests.cs @@ -0,0 +1,22 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; + +namespace FwDataMiniLcmBridge.Tests; + +[Collection(ProjectLoaderFixture.Name)] +public class WritingSystemTests(ProjectLoaderFixture fixture) +{ + [Fact] + public async Task GetWritingSystems_DoesNotReturnNullOrEmpty() + { + var writingSystems = await fixture.CreateApi("sena-3").GetWritingSystems(); + writingSystems.Vernacular.Should().NotBeNullOrEmpty(); + writingSystems.Analysis.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetWritingSystems_ReturnsExemplars() + { + var writingSystems = await fixture.CreateApi("sena-3").GetWritingSystems(); + writingSystems.Vernacular.Should().Contain(ws => ws.Exemplars.Any()); + } +} From 1921a1b8b973361c55ecfa640188963e191306ed Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 14 Jun 2024 15:16:49 -0600 Subject: [PATCH 04/38] reduce allocations in ContributeExemplars by ~50% by using spans of chars instead of strings. --- backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 4 ++-- backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index c4529fe70..a83a78e5f 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -137,7 +137,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) { var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) .Distinct() - .ToDictionary(ws => ws, ws => ws.Exemplars.ToHashSet()); + .ToDictionary(ws => ws, ws => ws.Exemplars.Select(s => s[0]).ToHashSet()); var wsExemplarsByHandle = wsExemplars.ToDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); foreach (var entry in _entriesRepository.AllInstances()) @@ -148,7 +148,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) foreach (var ws in wsExemplars.Keys) { - ws.Exemplars = [.. wsExemplars[ws].Order()]; + ws.Exemplars = [.. wsExemplars[ws].Order().Select(s => s.ToString())]; } } diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 351022e8c..31121d99f 100644 --- a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -53,15 +53,16 @@ internal static bool SearchValue(this ITsMultiString multiString, string value) '\u0640', // Arabic Tatweel ]; - internal static void ContributeExemplars(ITsMultiString multiString, Dictionary> wsExemplars) + internal static void ContributeExemplars(ITsMultiString multiString, Dictionary> wsExemplars) { for (var i = 0; i < multiString.StringCount; i++) { var tsString = multiString.GetStringFromIndex(i, out var ws); - var value = tsString.Text?.Trim(WhitespaceAndFormattingChars); - if (value?.Any() is true && wsExemplars.TryGetValue(ws, out var exemplars)) + if (string.IsNullOrEmpty(tsString.Text)) continue; + var value = tsString.Text.AsSpan().Trim(WhitespaceAndFormattingChars); + if (!value.IsEmpty && wsExemplars.TryGetValue(ws, out var exemplars)) { - exemplars.Add(value.First().ToString()); + exemplars.Add(value[0]); } } } From d3b25f2afe30d69863eb8e19b89d69caa3b514b0 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 14 Jun 2024 15:20:12 -0600 Subject: [PATCH 05/38] use string contains with StringComparison instead of calling ToLowerInvariant on each string. --- backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 31121d99f..7018b5c71 100644 --- a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -6,11 +6,11 @@ internal static class LcmHelpers { internal static bool SearchValue(this ITsMultiString multiString, string value) { - var valueLower = value.ToLowerInvariant(); for (var i = 0; i < multiString.StringCount; i++) { var tsString = multiString.GetStringFromIndex(i, out var _); - if (tsString.Text?.ToLowerInvariant().Contains(valueLower) is true) + if (string.IsNullOrEmpty(tsString.Text)) continue; + if (tsString.Text.Contains(value, StringComparison.InvariantCultureIgnoreCase)) { return true; } From c351dcd5237c23ff3d07b55ef2cb0c8739daefd3 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 14 Jun 2024 15:40:19 -0600 Subject: [PATCH 06/38] optimize ws lookup when populating exemplars by using a frozen dictionary, and set the MultiString capacity when converting a LcmMultiString to a MiniLcmMultiString --- backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 7 ++++--- backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs | 3 ++- backend/MiniLcm/MultiString.cs | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index a83a78e5f..31a5079a5 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1,4 +1,5 @@ -using FwDataMiniLcmBridge.Api.UpdateProxy; +using System.Collections.Frozen; +using FwDataMiniLcmBridge.Api.UpdateProxy; using Microsoft.Extensions.Logging; using MiniLcm; using SIL.LCModel; @@ -138,7 +139,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) .Distinct() .ToDictionary(ws => ws, ws => ws.Exemplars.Select(s => s[0]).ToHashSet()); - var wsExemplarsByHandle = wsExemplars.ToDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); + var wsExemplarsByHandle = wsExemplars.ToFrozenDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); foreach (var entry in _entriesRepository.AllInstances()) { @@ -235,7 +236,7 @@ private ExampleSentence FromLexExampleSentence(ILexExampleSentence sentence) private MultiString FromLcmMultiString(ITsMultiString multiString) { - var result = new MultiString(); + var result = new MultiString(multiString.StringCount); for (var i = 0; i < multiString.StringCount; i++) { var tsString = multiString.GetStringFromIndex(i, out var ws); diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 7018b5c71..5217ea1fc 100644 --- a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -53,7 +53,7 @@ internal static bool SearchValue(this ITsMultiString multiString, string value) '\u0640', // Arabic Tatweel ]; - internal static void ContributeExemplars(ITsMultiString multiString, Dictionary> wsExemplars) + internal static void ContributeExemplars(ITsMultiString multiString, IReadOnlyDictionary> wsExemplars) { for (var i = 0; i < multiString.StringCount; i++) { @@ -62,6 +62,7 @@ internal static void ContributeExemplars(ITsMultiString multiString, Dictionary< var value = tsString.Text.AsSpan().Trim(WhitespaceAndFormattingChars); if (!value.IsEmpty && wsExemplars.TryGetValue(ws, out var exemplars)) { + //todo should we upper or lowercase the value? exemplars.Add(value[0]); } } diff --git a/backend/MiniLcm/MultiString.cs b/backend/MiniLcm/MultiString.cs index f06c280e0..eca97e38e 100644 --- a/backend/MiniLcm/MultiString.cs +++ b/backend/MiniLcm/MultiString.cs @@ -12,6 +12,10 @@ namespace MiniLcm; [JsonConverter(typeof(MultiStringConverter))] public class MultiString: IDictionary { + public MultiString(int capacity) + { + Values = new MultiStringDict(capacity); + } public MultiString() { Values = new MultiStringDict(); @@ -41,6 +45,10 @@ private class MultiStringDict : Dictionary, #pragma warning restore CS8644 // Type does not implement interface member. Nullability of reference types in interface implemented by the base type doesn't match. IDictionary { + public MultiStringDict(int capacity) : base(capacity) + { + + } public MultiStringDict() { } From 8ebf4f943bf0fbb96eefe78248289049bb3fafd3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 18 Jun 2024 15:32:21 +0200 Subject: [PATCH 07/38] Populate UI with parts-of-speech --- .../Api/FwDataMiniLcmApi.cs | 4 +- backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs | 10 +++++ frontend/viewer/src/ProjectView.svelte | 21 ++++++++-- frontend/viewer/src/lib/config-data.ts | 21 ++++++---- frontend/viewer/src/lib/config-types.ts | 17 ++++---- .../lib/entry-editor/CrdtOptionField.svelte | 8 ++-- .../src/lib/entry-editor/FieldEditor.svelte | 10 +++-- .../entry-editor/SingleOptionEditor.svelte | 27 +++++++++++-- .../TypedSignalR.Client/index.ts | 40 +++++++++++++++++-- frontend/viewer/src/lib/i18n.ts | 8 ++-- frontend/viewer/src/lib/mini-lcm/i-sense.ts | 5 ++- frontend/viewer/src/lib/mini-lcm/index.ts | 2 + .../viewer/src/lib/mini-lcm/part-of-speech.ts | 7 ++++ .../src/lib/mini-lcm/semantic-domain.ts | 8 ++++ frontend/viewer/src/lib/mini-lcm/sense.ts | 5 ++- .../viewer/src/lib/services/lexbox-api.ts | 5 ++- .../src/lib/services/option-provider.ts | 9 +++++ frontend/viewer/src/lib/utils.ts | 9 ++++- 18 files changed, 168 insertions(+), 48 deletions(-) create mode 100644 frontend/viewer/src/lib/mini-lcm/part-of-speech.ts create mode 100644 frontend/viewer/src/lib/mini-lcm/semantic-domain.ts create mode 100644 frontend/viewer/src/lib/services/option-provider.ts diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 31a5079a5..dfd0918aa 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -165,7 +165,7 @@ public Task UpdateWritingSystem(WritingSystemId id, WritingSystem public async IAsyncEnumerable GetPartsOfSpeech() { - foreach (var partOfSpeech in _partOfSpeechRepository.AllInstances()) + foreach (var partOfSpeech in _partOfSpeechRepository.AllInstances().OrderBy(p => p.Name.BestAnalysisAlternative.Text)) { yield return new PartOfSpeech { Id = partOfSpeech.Guid, Name = FromLcmMultiString(partOfSpeech.Name) }; } @@ -173,7 +173,7 @@ public async IAsyncEnumerable GetPartsOfSpeech() public async IAsyncEnumerable GetSemanticDomains() { - foreach (var semanticDomain in _semanticDomainRepository.AllInstances()) + foreach (var semanticDomain in _semanticDomainRepository.AllInstances().OrderBy(p => p.Name.BestAnalysisAlternative.Text)) { yield return new SemanticDomain { diff --git a/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs b/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs index 3c5527320..039f1b929 100644 --- a/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs +++ b/backend/LocalWebApp/Hubs/FwDataMiniLcmHub.cs @@ -30,6 +30,16 @@ public async Task UpdateWritingSystem(WritingSystemId id, Writing return writingSystem; } + public IAsyncEnumerable GetPartsOfSpeech() + { + return lexboxApi.GetPartsOfSpeech(); + } + + public IAsyncEnumerable GetSemanticDomains() + { + return lexboxApi.GetSemanticDomains(); + } + public IAsyncEnumerable GetEntriesForExemplar(string exemplar, QueryOptions? options = null) { throw new NotImplementedException(); diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index d8ea67ca4..219bd695f 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -2,12 +2,12 @@ import {AppBar, Button, ProgressCircle} from 'svelte-ux'; import {mdiArrowCollapseLeft, mdiArrowCollapseRight, mdiArrowLeft, mdiEyeSettingsOutline} from '@mdi/js'; import Editor from './lib/Editor.svelte'; - import {headword} from './lib/utils'; + import {headword, pickBestAlternative} from './lib/utils'; import {views} from './lib/config-data'; import {useLexboxApi} from './lib/services/service-provider'; import type {IEntry} from './lib/mini-lcm'; import {setContext} from 'svelte'; - import {derived, writable, type Readable} from 'svelte/store'; + import {derived, readable, writable, type Readable} from 'svelte/store'; import {deriveAsync} from './lib/utils/time'; import {type ViewConfig, type LexboxPermissions, type ViewOptions, type LexboxFeatures} from './lib/config-types'; import ViewOptionsDrawer from './lib/layout/ViewOptionsDrawer.svelte'; @@ -18,6 +18,7 @@ import NewEntryDialog from './lib/entry-editor/NewEntryDialog.svelte'; import SearchBar from './lib/search-bar/SearchBar.svelte'; import ActivityView from './lib/activity/ActivityView.svelte'; + import type { OptionProvider } from './lib/services/option-provider'; export let loading = false; @@ -73,6 +74,20 @@ trigger.update(t => t + 1); } + const partsOfSpeech = deriveAsync(connected, isConnected => { + if (!isConnected) return Promise.resolve(null); + return lexboxApi.GetPartsOfSpeech(); + }); + const semanticDomains = deriveAsync(connected, isConnected => { + if (!isConnected) return Promise.resolve(null); + return lexboxApi.GetSemanticDomains(); + }); + const optionProvider: OptionProvider = { + partsOfSpeech: derived([writingSystems, partsOfSpeech], ([ws, pos]) => pos?.map(option => ({ value: option.id, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), + semanticDomains: derived([writingSystems, semanticDomains], ([ws, sd]) => sd?.map(option => ({ value: option, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), + }; + setContext('optionProvider', optionProvider); + const _entries = deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { return fetchEntries(s, isConnected, exemplar); }, undefined, 200); @@ -119,7 +134,7 @@ } - $: _loading = !$entries || !$writingSystems || loading; + $: _loading = !$entries || !$writingSystems || !$partsOfSpeech || !$semanticDomains || loading; function onEntryCreated(entry: IEntry) { $entries?.push(entry);//need to add it before refresh, otherwise it won't get selected because it's not in the list diff --git a/frontend/viewer/src/lib/config-data.ts b/frontend/viewer/src/lib/config-data.ts index 96403c001..b609ed39f 100644 --- a/frontend/viewer/src/lib/config-data.ts +++ b/frontend/viewer/src/lib/config-data.ts @@ -2,6 +2,7 @@ import type { BaseEntityFieldConfig, CustomFieldConfig, FieldConfig, ViewConfigF import type { IEntry, IExampleSentence, ISense } from './mini-lcm'; import type { I18nType } from './i18n'; +import type { ConditionalPickDeep, ValueOf } from 'type-fest'; const allFieldConfigs = ({ entry: { @@ -16,8 +17,8 @@ const allFieldConfigs = ({ sense: { gloss: { id: 'gloss', type: 'multi', ws: 'analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/Gloss_field_Sense.htm' }, definition: { id: 'definition', type: 'multi', ws: 'analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/definition_field.htm' }, - partOfSpeech: { id: 'partOfSpeech', type: 'option', optionType: 'part-of-speech', ws: 'first-analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/Grammatical_Info_field.htm' }, - semanticDomain: { id: 'semanticDomain', type: 'multi-option', optionType: 'semantic-domain', ws: 'first-analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/semantic_domains_field.htm' } + partOfSpeechId: { id: 'partOfSpeechId', type: 'option', optionType: 'part-of-speech', ws: 'first-analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/Grammatical_Info_field.htm' }, + semanticDomains: { id: 'semanticDomains', type: 'multi-option', optionType: 'semantic-domain', ws: 'first-analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/semantic_domains_field.htm' } }, customSense: { custom1: { id: 'sense-custom-001', type: 'multi', ws: 'first-analysis', name: 'Custom sense', custom: true }, @@ -38,7 +39,11 @@ const allFieldConfigs = ({ customExample: Record, }; -export function allFields(viewConfig: ViewConfig): FieldConfig[] { +type FieldOptionType = T['optionType']; +export type WellKnownOptionType = FieldOptionType['sense']>>; +export type OptionType = WellKnownOptionType | Omit; + +export function allFields(viewConfig: ViewConfig): Readonly { return [ ...Object.values(viewConfig.entry), ...Object.values(viewConfig.customEntry ?? {}), @@ -46,7 +51,7 @@ export function allFields(viewConfig: ViewConfig): FieldConfig[] { ...Object.values(viewConfig.customSense ?? {}), ...Object.values(viewConfig.example), ...Object.values(viewConfig.customExample ?? {}), - ]; + ] as const; } type FieldsWithViewConfigProps>> = @@ -88,8 +93,8 @@ export const views: ViewConfig[] = [ }, sense: { gloss: allFieldConfigs.sense.gloss, - partOfSpeech: allFieldConfigs.sense.partOfSpeech, - semanticDomain: configure(allFieldConfigs.sense.semanticDomain, { extra: true }), + partOfSpeechId: allFieldConfigs.sense.partOfSpeechId, + semanticDomains: configure(allFieldConfigs.sense.semanticDomains, { extra: true }), }, example: { sentence: allFieldConfigs.example.sentence, @@ -106,8 +111,8 @@ export const views: ViewConfig[] = [ sense: { gloss: allFieldConfigs.sense.gloss, definition: allFieldConfigs.sense.definition, - partOfSpeech: allFieldConfigs.sense.partOfSpeech, - semanticDomain: allFieldConfigs.sense.semanticDomain, + partOfSpeechId: allFieldConfigs.sense.partOfSpeechId, + semanticDomains: allFieldConfigs.sense.semanticDomains, }, example: { sentence: allFieldConfigs.example.sentence, diff --git a/frontend/viewer/src/lib/config-types.ts b/frontend/viewer/src/lib/config-types.ts index 0ab11617b..3b99ef7a9 100644 --- a/frontend/viewer/src/lib/config-types.ts +++ b/frontend/viewer/src/lib/config-types.ts @@ -1,4 +1,4 @@ -import type { IEntry, IExampleSentence, IMultiString, ISense } from './mini-lcm'; +import type { IEntry, IExampleSentence, IMultiString, ISense, SemanticDomain } from './mini-lcm'; import type { ConditionalKeys } from 'type-fest'; import type { LexboxApiFeatures } from './services/lexbox-api'; @@ -21,6 +21,12 @@ export type CustomFieldConfig = BaseFieldConfig & { custom: true; } +export type OptionFieldConfig = { + type: `option`; + optionType: string; + ws: `first-${WritingSystemType}`; +} + export type BaseEntityFieldConfig = (({ type: 'multi'; id: ConditionalKeys; @@ -28,15 +34,10 @@ export type BaseEntityFieldConfig = (({ type: 'single'; id: ConditionalKeys; ws: `first-${WritingSystemType}`; -} | { - type: `option`; - optionType: string; - id: ConditionalKeys; - ws: `first-${WritingSystemType}`; -} | { +} | (OptionFieldConfig & {id: ConditionalKeys}) | { type: `multi-option`; optionType: string; - id: ConditionalKeys; + id: ConditionalKeys; ws: `first-${WritingSystemType}`; }) & BaseFieldConfig & { id: WellKnownFieldId, diff --git a/frontend/viewer/src/lib/entry-editor/CrdtOptionField.svelte b/frontend/viewer/src/lib/entry-editor/CrdtOptionField.svelte index 5ad4e359b..003b1cdb2 100644 --- a/frontend/viewer/src/lib/entry-editor/CrdtOptionField.svelte +++ b/frontend/viewer/src/lib/entry-editor/CrdtOptionField.svelte @@ -5,14 +5,12 @@ export let value: string; export let unsavedChanges = false; + export let options: MenuOption[] | undefined = undefined; export let label: string | undefined = undefined; export let labelPlacement: ComponentProps['labelPlacement'] = undefined; export let placeholder: string | undefined = undefined; - export let readonly: true | undefined = undefined; + export let readonly: boolean | undefined = undefined; let append: HTMLElement; - - let demoOptions: MenuOption[] | undefined; - $: demoOptions = demoOptions ?? [{label: value, value: value}, {label: 'Another option', value: 'Another option'}]; @@ -20,7 +18,7 @@ on:change={(e) => onEditorValueChange(e.detail.value, true)} value={editorValue} disabled={readonly} - options={demoOptions ?? []} + {options} clearSearchOnOpen={false} clearable={false} search={() => Promise.resolve()} diff --git a/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte b/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte index 2286d08c3..79d9b54a1 100644 --- a/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte @@ -1,6 +1,6 @@ - + $search = undefined} class="w-[700px]" classes={{root: 'items-start', title: 'p-2'}}>
{ dispatch('entrySelected', entry); showSearchDialog = false; - $search = undefined; }} /> {/each} @@ -100,10 +102,23 @@
{/if} {#if $result.entries.length > $displayedEntries.length} -
+
{$result.entries.length - $displayedEntries.length} {#if $result.entries.length === fetchCount}+{/if} - more matching entries... +
+ more matching entries... + +
{/if}
From 614dbd5018d5e3a6c38c2e031f3209f7e9b7b041 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 20 Jun 2024 09:08:52 +0200 Subject: [PATCH 09/38] Index dictionary by first grapheme instead of first char --- .../Api/FwDataMiniLcmApi.cs | 4 ++-- backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs | 18 ++++++++++-------- frontend/viewer/src/ProjectView.svelte | 3 ++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index dfd0918aa..04cdca95e 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -138,7 +138,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) { var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) .Distinct() - .ToDictionary(ws => ws, ws => ws.Exemplars.Select(s => s[0]).ToHashSet()); + .ToDictionary(ws => ws, ws => ws.Exemplars.ToHashSet()); var wsExemplarsByHandle = wsExemplars.ToFrozenDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); foreach (var entry in _entriesRepository.AllInstances()) @@ -149,7 +149,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) foreach (var ws in wsExemplars.Keys) { - ws.Exemplars = [.. wsExemplars[ws].Order().Select(s => s.ToString())]; + ws.Exemplars = [.. wsExemplars[ws].Order()]; } } diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 5217ea1fc..be0260d26 100644 --- a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -1,3 +1,4 @@ +using System.Globalization; using SIL.LCModel.Core.KernelInterfaces; namespace FwDataMiniLcmBridge.Api; @@ -38,32 +39,33 @@ internal static bool SearchValue(this ITsMultiString multiString, string value) '\u200C', // Zero Width Non-Joiner '\u200D', // Zero Width Joiner '\u200E', // Left-to-Right Mark - '\u200F', // Right-to-Left Mark + '\u200F', // Right-to-Left Mark '\u2028', // Line Separator '\u2029', // Paragraph Separator '\u202F', // Narrow No-Break Space '\u205F', // Medium Mathematical Space - '\u3000', // Ideographic Space + '\u3000', // Ideographic Space '\uFEFF', // Zero Width No-Break Space / BOM ]; internal static readonly char[] WhitespaceAndFormattingChars = [ .. WhitespaceChars, - '\u0640', // Arabic Tatweel + '\u0640'.ToString().Normalize(System.Text.NormalizationForm.FormD)[0], // Arabic Tatweel ]; - internal static void ContributeExemplars(ITsMultiString multiString, IReadOnlyDictionary> wsExemplars) + internal static void ContributeExemplars(ITsMultiString multiString, IReadOnlyDictionary> wsExemplars) { for (var i = 0; i < multiString.StringCount; i++) { var tsString = multiString.GetStringFromIndex(i, out var ws); if (string.IsNullOrEmpty(tsString.Text)) continue; - var value = tsString.Text.AsSpan().Trim(WhitespaceAndFormattingChars); - if (!value.IsEmpty && wsExemplars.TryGetValue(ws, out var exemplars)) + var value = new StringInfo(tsString.Text).SubstringByTextElements(0, 1) + // in some cases we need to trim things both before and after the grapheme + .Trim(WhitespaceAndFormattingChars); + if (value.Length > 0 && wsExemplars.TryGetValue(ws, out var exemplars)) { - //todo should we upper or lowercase the value? - exemplars.Add(value[0]); + exemplars.Add(value.ToUpper()); } } } diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index a8e19f011..62df7425d 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -148,7 +148,8 @@ function selectEntry(entry: IEntry) { $selectedEntry = entry; - $selectedIndexExemplar = headword(entry).charAt(0).toLocaleUpperCase() || undefined; + const indexChar: string | undefined = new Intl.Segmenter().segment(headword(entry))[Symbol.iterator]().next()?.value?.segment; + $selectedIndexExemplar = indexChar?.toLocaleUpperCase() ?? undefined; refreshEntries(); pickedEntry = true; } From dcf19702aaa4290c915c15171a0237a1ca97a2e1 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 20 Jun 2024 09:09:26 +0200 Subject: [PATCH 10/38] Revert "Index dictionary by first grapheme instead of first char", because first-char sounds like a better way to go (https://sil-lt.slack.com/archives/C806BLR42/p1718797173733449) This reverts commit 614dbd5018d5e3a6c38c2e031f3209f7e9b7b041. --- .../Api/FwDataMiniLcmApi.cs | 4 ++-- backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs | 18 ++++++++---------- frontend/viewer/src/ProjectView.svelte | 3 +-- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 04cdca95e..dfd0918aa 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -138,7 +138,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) { var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) .Distinct() - .ToDictionary(ws => ws, ws => ws.Exemplars.ToHashSet()); + .ToDictionary(ws => ws, ws => ws.Exemplars.Select(s => s[0]).ToHashSet()); var wsExemplarsByHandle = wsExemplars.ToFrozenDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); foreach (var entry in _entriesRepository.AllInstances()) @@ -149,7 +149,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) foreach (var ws in wsExemplars.Keys) { - ws.Exemplars = [.. wsExemplars[ws].Order()]; + ws.Exemplars = [.. wsExemplars[ws].Order().Select(s => s.ToString())]; } } diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index be0260d26..5217ea1fc 100644 --- a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -1,4 +1,3 @@ -using System.Globalization; using SIL.LCModel.Core.KernelInterfaces; namespace FwDataMiniLcmBridge.Api; @@ -39,33 +38,32 @@ internal static bool SearchValue(this ITsMultiString multiString, string value) '\u200C', // Zero Width Non-Joiner '\u200D', // Zero Width Joiner '\u200E', // Left-to-Right Mark - '\u200F', // Right-to-Left Mark + '\u200F', // Right-to-Left Mark '\u2028', // Line Separator '\u2029', // Paragraph Separator '\u202F', // Narrow No-Break Space '\u205F', // Medium Mathematical Space - '\u3000', // Ideographic Space + '\u3000', // Ideographic Space '\uFEFF', // Zero Width No-Break Space / BOM ]; internal static readonly char[] WhitespaceAndFormattingChars = [ .. WhitespaceChars, - '\u0640'.ToString().Normalize(System.Text.NormalizationForm.FormD)[0], // Arabic Tatweel + '\u0640', // Arabic Tatweel ]; - internal static void ContributeExemplars(ITsMultiString multiString, IReadOnlyDictionary> wsExemplars) + internal static void ContributeExemplars(ITsMultiString multiString, IReadOnlyDictionary> wsExemplars) { for (var i = 0; i < multiString.StringCount; i++) { var tsString = multiString.GetStringFromIndex(i, out var ws); if (string.IsNullOrEmpty(tsString.Text)) continue; - var value = new StringInfo(tsString.Text).SubstringByTextElements(0, 1) - // in some cases we need to trim things both before and after the grapheme - .Trim(WhitespaceAndFormattingChars); - if (value.Length > 0 && wsExemplars.TryGetValue(ws, out var exemplars)) + var value = tsString.Text.AsSpan().Trim(WhitespaceAndFormattingChars); + if (!value.IsEmpty && wsExemplars.TryGetValue(ws, out var exemplars)) { - exemplars.Add(value.ToUpper()); + //todo should we upper or lowercase the value? + exemplars.Add(value[0]); } } } diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index 62df7425d..a8e19f011 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -148,8 +148,7 @@ function selectEntry(entry: IEntry) { $selectedEntry = entry; - const indexChar: string | undefined = new Intl.Segmenter().segment(headword(entry))[Symbol.iterator]().next()?.value?.segment; - $selectedIndexExemplar = indexChar?.toLocaleUpperCase() ?? undefined; + $selectedIndexExemplar = headword(entry).charAt(0).toLocaleUpperCase() || undefined; refreshEntries(); pickedEntry = true; } From ce8a06d8a80a9d7863aff248a4d33227fcb9a90d Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 20 Jun 2024 14:52:24 +0200 Subject: [PATCH 11/38] Fix exemplar lookup not always working --- .../FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index dfd0918aa..4fa2e608c 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -262,9 +262,20 @@ public async IAsyncEnumerable GetEntries( if (options.Exemplar is not null) { var ws = GetWritingSystemHandle(options.Exemplar.WritingSystem, WritingSystemType.Vernacular); - entries = entries.Where(e => (e.CitationForm.get_String(ws).Text ?? e.LexemeFormOA.Form.get_String(ws).Text)? - .Trim(LcmHelpers.WhitespaceAndFormattingChars) - .StartsWith(options.Exemplar.Value, StringComparison.InvariantCultureIgnoreCase) ?? false); + entries = entries.Where(e => + { + var value = (e.CitationForm.get_String(ws).Text ?? e.LexemeFormOA.Form.get_String(ws).Text)? + .Trim(LcmHelpers.WhitespaceAndFormattingChars); + if ((value?.Length ?? 0) < options.Exemplar.Value.Length) return false; + for (var i = 0; i < options.Exemplar.Value.Length; i++) + { + // We compare chars, because there are cases where value.StartsWith(value[0].ToString()) == false (e.g. "آبراهام") + // Perhaps string.StartsWith compares the first grapheme cluster of value, which could be multiple characters. + // Comparing value.AsSpan().StartsWith() also didn't work + if (char.ToUpperInvariant(value![i]) != char.ToUpperInvariant(options.Exemplar.Value[i])) return false; + } + return true; + }); } var sortWs = GetWritingSystemHandle(options.Order.WritingSystem, WritingSystemType.Vernacular); From cfebb611ceb808d4f0959ebbeedeeb604f8fc8e5 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 20 Jun 2024 14:55:09 +0200 Subject: [PATCH 12/38] Add loading indicators --- frontend/viewer/src/ProjectView.svelte | 146 +++++++++--------- .../viewer/src/lib/layout/EntryList.svelte | 12 +- .../src/lib/search-bar/SearchBar.svelte | 23 ++- frontend/viewer/src/lib/utils/time.ts | 35 +++-- 4 files changed, 124 insertions(+), 92 deletions(-) diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index a8e19f011..35a55a00f 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -60,7 +60,7 @@ const selectedIndexExemplar = writable(undefined); setContext('selectedIndexExamplar', selectedIndexExemplar); - const writingSystems = deriveAsync(connected, isConnected => { + const { value: writingSystems } = deriveAsync(connected, isConnected => { if (!isConnected) return Promise.resolve(null); return lexboxApi.GetWritingSystems(); }); @@ -70,15 +70,12 @@ }); setContext('indexExamplars', indexExamplars); const trigger = writable(0); - function refreshEntries(): void { - trigger.update(t => t + 1); - } - const partsOfSpeech = deriveAsync(connected, isConnected => { + const { value: partsOfSpeech } = deriveAsync(connected, isConnected => { if (!isConnected) return Promise.resolve(null); return lexboxApi.GetPartsOfSpeech(); }); - const semanticDomains = deriveAsync(connected, isConnected => { + const { value: semanticDomains } = deriveAsync(connected, isConnected => { if (!isConnected) return Promise.resolve(null); return lexboxApi.GetSemanticDomains(); }); @@ -88,10 +85,16 @@ }; setContext('optionProvider', optionProvider); - const _entries = deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { - return fetchEntries(s, isConnected, exemplar); + const { value: _entries, loading: loadingEntries, flush: flushLoadingEntries } = + deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { + return fetchEntries(s, isConnected, exemplar); }, undefined, 200); + function refreshEntries(): void { + trigger.update(t => t + 1); + setTimeout(flushLoadingEntries); + } + // TODO: replace with either // 1 something like setContext('editorEntry') that even includes unsaved changes // 2 somehow use selectedEntry in components that need to refresh on changes @@ -168,6 +171,14 @@ {projectName} + +{#if _loading || !$entries} +
+
+ Loading {projectName}... +
+
+{:else}
@@ -196,77 +207,68 @@ {/if}
- - {#if _loading || !$entries} -
-
- Loading... +
+
+
+ pickedEntry = true} />
-
- {:else} -
-
-
- pickedEntry = true} /> +
+ {#if $selectedEntry} +
+ +
+ { + $selectedEntry = $selectedEntry; + $entries = $entries; + }} + on:delete={e => { + $selectedEntry = undefined; + refreshEntries(); + }} /> + {:else} +
+ No entry selected + {#if !$viewConfig.readonly} + onEntryCreated(e.detail.entry)}/> + {/if} +
+ {/if} +
+
+ -
- {#if $selectedEntry} -
- -
- { - $selectedEntry = $selectedEntry; - $entries = $entries; - }} - on:delete={e => { - $selectedEntry = undefined; - refreshEntries(); - }} /> - {:else} -
- No entry selected +
+ {#if $selectedEntry && !expandList} +
{#if !$viewConfig.readonly} - onEntryCreated(e.detail.entry)}/> - {/if} -
- {/if} -
-
- -
- {#if $selectedEntry && !expandList} -
- {#if !$viewConfig.readonly} -
+
-
- {/if} -
-
+ {/if} +
+
- - {$viewConfig.activeView.label} -
+
+ + {$viewConfig.activeView.label} +
-
- - +
+ - {/if} +
+{/if} diff --git a/frontend/viewer/src/lib/layout/EntryList.svelte b/frontend/viewer/src/lib/layout/EntryList.svelte index 34ae6685d..cf29d9ec2 100644 --- a/frontend/viewer/src/lib/layout/EntryList.svelte +++ b/frontend/viewer/src/lib/layout/EntryList.svelte @@ -1,5 +1,5 @@ @@ -33,8 +33,8 @@ {:else if isSingleOption(state)} -{:else if isMultiOption(value)} - +{:else if isMultiOption(state)} + {/if} -
-
- - - - - - {#if loggedIn} -

{username}

- - {:else} - - {/if} -
- -
- -
- {#await projectsPromise} -

loading...

- {:then projects} - - - - {#each data ?? [] as rowData, rowIndex} - - {#each columns as column (column.name)} - {@const value = getCellValue(column, rowData, rowIndex)} - - - {/each} - - {/each} - -
- {#if column.name === "fwdata"} - {#if rowData.fwdata} - - {/if} - {:else if column.name === "lexbox"} - {#if rowData.lexbox && !rowData.crdt} - - {:else if !rowData.lexbox && rowData.crdt && loggedIn} - - {:else if rowData.lexbox && rowData.crdt} - - {/if} - {:else if column.name === "crdt"} - {#if rowData.crdt} - - {:else if rowData.fwdata} - - {/if} - {:else} - {getCellContent(column, rowData, rowIndex)} - {/if} -
- {/await} - - navigate('/testing/project-view')}/> -
-
- -
From e6e55721adb33bcc00518fa79000f7eb4cd3d78a Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 20 Jun 2024 18:25:53 +0200 Subject: [PATCH 23/38] Resize keys --- frontend/viewer/src/app.postcss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss index 92d8f19cf..bf179c5ce 100644 --- a/frontend/viewer/src/app.postcss +++ b/frontend/viewer/src/app.postcss @@ -66,9 +66,9 @@ .key { display: inline-block; - padding: 0.2em 0.3em; - margin: 0.1em; - font-size: 0.9em; + padding: 0.15em 0.4em; + margin: 0 0.1em; + font-size: 0.8em; @apply border border-surface-content rounded-md shadow-md; } } From 834925fdf9fd605b6f98d3b42d989c5b21ddfd61 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 10:39:42 +0200 Subject: [PATCH 24/38] Fix Part of speech changes not being persisted --- .../viewer/src/lib/entry-editor/FieldEditor.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte b/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte index c285472fd..ee70728cf 100644 --- a/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/FieldEditor.svelte @@ -11,7 +11,17 @@ export let value: unknown; export let field: FieldConfig; - $: state = {value, field}; + // having a single state object lets us do type predicates on the value and field at once + // we just have to make sure they stay in sync + const state = {value, field}; + $: syncToState(value); + function syncToState(_: unknown): void { + if (state.value !== value) state.value = value; + } + $: syncToValue(state.value); + function syncToValue(_: unknown): void { + if (state.value !== value) value = state.value; + } function isMultiString(value: unknown): value is MultiString { return field.type === 'multi'; From 08727e910a844d80534a2953df0f99e0a39f12d8 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 11:53:10 +0200 Subject: [PATCH 25/38] Uppercase index exemplars to prevent duplicates --- backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 5217ea1fc..654338244 100644 --- a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs +++ b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs @@ -62,8 +62,7 @@ internal static void ContributeExemplars(ITsMultiString multiString, IReadOnlyDi var value = tsString.Text.AsSpan().Trim(WhitespaceAndFormattingChars); if (!value.IsEmpty && wsExemplars.TryGetValue(ws, out var exemplars)) { - //todo should we upper or lowercase the value? - exemplars.Add(value[0]); + exemplars.Add(char.ToUpperInvariant(value[0])); } } } From a9667a5ff1fb0c034effe15157adcc5bda104d31 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 11:54:23 +0200 Subject: [PATCH 26/38] Store selected-entry, selected-index-char and search in URL --- frontend/viewer/src/ProjectView.svelte | 50 ++++++++++++++----- .../viewer/src/lib/utils/search-params.ts | 22 ++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 frontend/viewer/src/lib/utils/search-params.ts diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index 31ba9b69e..f59e8ca9d 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -20,6 +20,7 @@ import ActivityView from './lib/activity/ActivityView.svelte'; import type { OptionProvider } from './lib/services/option-provider'; import { getAvailableHeightForElement } from './lib/utils/size'; + import { ViewerSearchParam, getSearchParam, updateSearchParam } from './lib/utils/search-params'; export let loading = false; @@ -55,11 +56,13 @@ $: connected.set(isConnected); const connected = writable(false); - const search = writable(''); + const search = writable(getSearchParam(ViewerSearchParam.Search)); setContext('listSearch', search); + $: updateSearchParam(ViewerSearchParam.Search, $search); - const selectedIndexExemplar = writable(undefined); + const selectedIndexExemplar = writable(getSearchParam(ViewerSearchParam.IndexCharacter)); setContext('selectedIndexExamplar', selectedIndexExemplar); + $: updateSearchParam(ViewerSearchParam.IndexCharacter, $selectedIndexExemplar); const { value: writingSystems } = deriveAsync(connected, isConnected => { if (!isConnected) return Promise.resolve(null); @@ -101,11 +104,11 @@ // 2 somehow use selectedEntry in components that need to refresh on changes // 3 combine 1 into 2 // Used for triggering rerendering when display values of the current entry change (e.g. the headword in the list view) - const entries = writable(); + const entries = writable(); $: $entries = $_entries; function fetchEntries(s: string, isConnected: boolean, exemplar: string | undefined) { - if (!isConnected) return Promise.resolve([]); + if (!isConnected) return Promise.resolve(undefined); return lexboxApi.SearchEntries(s ?? '', { offset: 0, // we always load full exampelar lists for now, so we can guaruntee that the selected entry is in the list @@ -116,8 +119,17 @@ } let showOptionsDialog = false; + let pickedEntry = false; + let navigateToEntryIdOnLoad = getSearchParam(ViewerSearchParam.EntryId); const selectedEntry = writable(undefined); setContext('selectedEntry', selectedEntry); + // For some reason reactive syntax doesn't pick up every change, so we need to manually subscribe + // and we need the extra call to updateEntryIdSearchParam in refreshSelection + const unsubSelectedEntry = selectedEntry.subscribe(updateEntryIdSearchParam); + $: { pickedEntry; updateEntryIdSearchParam(); } + function updateEntryIdSearchParam() { + updateSearchParam(ViewerSearchParam.EntryId, navigateToEntryIdOnLoad ?? (pickedEntry ? $selectedEntry?.id : undefined)); + } $: { $entries; @@ -126,17 +138,31 @@ //selection handling, make sure the selected entry is always in the list of entries function refreshSelection() { - let currentEntry = $selectedEntry; - if (currentEntry !== undefined) { - const entry = $entries.find(e => e.id === currentEntry.id); - if (entry !== currentEntry) { + if (!$entries) return; + + if ($selectedEntry !== undefined) { + const entry = $entries.find(e => e.id === $selectedEntry!.id); + if (entry !== $selectedEntry) { + $selectedEntry = entry; + } + } else if (navigateToEntryIdOnLoad) { + const entry = $entries.find(e => e.id === navigateToEntryIdOnLoad); + if (entry) { $selectedEntry = entry; } } - if (!$selectedEntry && $entries?.length > 0) - $selectedEntry = $entries[0]; - } + if ($selectedEntry) { + pickedEntry = true; + } else { + pickedEntry = false; + if ($entries?.length > 0) + $selectedEntry = $entries[0]; + } + + updateEntryIdSearchParam(); + navigateToEntryIdOnLoad = undefined; + } $: _loading = !$entries || !$writingSystems || !$partsOfSpeech || !$semanticDomains || loading; @@ -159,7 +185,6 @@ let expandList = false; let collapseActionBar = false; - let pickedEntry = false; let entryActionsElem: HTMLDivElement; const entryActionsPortal = writable<{target: HTMLDivElement, collapsed: boolean}>(); @@ -181,6 +206,7 @@ window.addEventListener('scroll', updateSpaceForEditor, abortController); return () => { abortController.abort(); + unsubSelectedEntry(); }; }); diff --git a/frontend/viewer/src/lib/utils/search-params.ts b/frontend/viewer/src/lib/utils/search-params.ts new file mode 100644 index 000000000..0ca9e2264 --- /dev/null +++ b/frontend/viewer/src/lib/utils/search-params.ts @@ -0,0 +1,22 @@ + +export const enum ViewerSearchParam { + EntryId = 'entryId', + IndexCharacter = 'indexCharacter', + Search = 'search', +} + +const urlSearchParams = new URLSearchParams(window.location.search); + +export function getSearchParam(param: ViewerSearchParam): string | undefined { + return urlSearchParams.get(param) ?? undefined; +} + +export function updateSearchParam(param: ViewerSearchParam, value: string | undefined) { + const current = urlSearchParams.get(param) ?? undefined; + if (current == value) return; + if (value) urlSearchParams.set(param, value); + else urlSearchParams.delete(param); + let paramString = urlSearchParams.toString(); + if (paramString.length) paramString = `?${paramString}`; + window.history.pushState({}, '', `${window.location.pathname}${paramString}`); +} From 1804a406cb3620c615ac0ee3b630fc26fe5d7878 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 13:21:54 +0200 Subject: [PATCH 27/38] Add transition so increased debounce is not so noticeable. --- frontend/viewer/src/ProjectView.svelte | 4 ++-- frontend/viewer/src/app.postcss | 13 +++++++------ frontend/viewer/src/lib/layout/EntryList.svelte | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index f59e8ca9d..f91aa6333 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -197,7 +197,7 @@ if (!editorElem) return; const availableHeight = getAvailableHeightForElement(editorElem); spaceForEditorStyle = `--space-for-editor: ${availableHeight}px`; - }, 15).debounce; + }, 30).debounce; $: editorElem && updateSpaceForEditor(); onMount(() => { @@ -256,7 +256,7 @@ class="grid flex-grow items-start justify-stretch md:justify-center" style="grid-template-columns: minmax(0, min-content) minmax(0, min-content) minmax(0, min-content);" > -
+
pickedEntry = true} />
diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss index bf179c5ce..792001892 100644 --- a/frontend/viewer/src/app.postcss +++ b/frontend/viewer/src/app.postcss @@ -42,17 +42,18 @@ grid-template-columns: 170px fit-content(80px) 1fr; } - .side-scroller { - max-height: calc(var(--space-for-editor, 100vh) - 32px); - position: sticky; - top: 16px; - } - .collapsible-col { overflow-x: hidden; transition: opacity 0.2s ease-out; } + .side-scroller { + height: calc(var(--space-for-editor, 100vh) - 32px); + transition: height 0.1s ease-out, opacity 0.2s ease-out; + position: sticky; + top: 16px; + } + .collapsible-col.collapse-col { max-height: 0 !important; width: 0 !important; diff --git a/frontend/viewer/src/lib/layout/EntryList.svelte b/frontend/viewer/src/lib/layout/EntryList.svelte index cf29d9ec2..e453ecf99 100644 --- a/frontend/viewer/src/lib/layout/EntryList.svelte +++ b/frontend/viewer/src/lib/layout/EntryList.svelte @@ -50,7 +50,7 @@ const selectedCharacter = getContext>('selectedIndexExamplar'); -
+
From 9a15ec63c29a30964c805e3736f2e35841c6fc20 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 16:17:45 +0200 Subject: [PATCH 28/38] Add save status indicator --- frontend/viewer/src/ProjectView.svelte | 12 +- frontend/viewer/src/lib/Editor.svelte | 20 +-- .../lib/entry-editor/NewEntryDialog.svelte | 6 +- .../src/lib/services/save-event-service.ts | 19 +++ .../viewer/src/lib/status/SaveStatus.svelte | 115 ++++++++++++++++++ frontend/viewer/tailwind.config.cjs | 3 +- 6 files changed, 161 insertions(+), 14 deletions(-) create mode 100644 frontend/viewer/src/lib/services/save-event-service.ts create mode 100644 frontend/viewer/src/lib/status/SaveStatus.svelte diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index f91aa6333..073d8fe9d 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -21,12 +21,16 @@ import type { OptionProvider } from './lib/services/option-provider'; import { getAvailableHeightForElement } from './lib/utils/size'; import { ViewerSearchParam, getSearchParam, updateSearchParam } from './lib/utils/search-params'; + import SaveStatus from './lib/status/SaveStatus.svelte'; + import { saveEventDispatcher, saveHandler } from './lib/services/save-event-service'; export let loading = false; const lexboxApi = useLexboxApi(); const features = writable(lexboxApi.SupportedFeatures()); setContext>('features', features); + setContext('saveEvents', saveEventDispatcher); + setContext('saveHandler', saveHandler); const permissions = writable({ write: true, @@ -224,15 +228,19 @@
{:else}
- +
+
+ +
+
navigateToEntry(e.detail)} />
-
+
{#if !$viewConfig.readonly} onEntryCreated(e.detail.entry)} /> diff --git a/frontend/viewer/src/lib/Editor.svelte b/frontend/viewer/src/lib/Editor.svelte index dc291a4b8..de78ea2b8 100644 --- a/frontend/viewer/src/lib/Editor.svelte +++ b/frontend/viewer/src/lib/Editor.svelte @@ -7,8 +7,10 @@ import jsonPatch from 'fast-json-patch'; import {useLexboxApi} from './services/service-provider'; import {isEmptyId} from './utils'; + import type { SaveHandler } from './services/save-event-service'; - let lexboxApi = useLexboxApi(); + const lexboxApi = useLexboxApi(); + const saveHandler = getContext('saveHandler'); const dispatch = createEventDispatcher<{ delete: { entry: IEntry }; @@ -48,11 +50,11 @@ async function onDelete(e: { entry: IEntry, sense?: ISense, example?: IExampleSentence }) { if (e.example !== undefined && e.sense !== undefined) { - await lexboxApi.DeleteExampleSentence(e.entry.id, e.sense.id, e.example.id); + await saveHandler(() => lexboxApi.DeleteExampleSentence(e.entry.id, e.sense!.id, e.example!.id)); } else if (e.sense !== undefined) { - await lexboxApi.DeleteSense(e.entry.id, e.sense.id); + await saveHandler(() => lexboxApi.DeleteSense(e.entry.id, e.sense!.id)); } else { - await lexboxApi.DeleteEntry(e.entry.id); + await saveHandler(() => lexboxApi.DeleteEntry(e.entry.id)); dispatch('delete', {entry: e.entry}); return; } @@ -63,20 +65,20 @@ if (entry.id != updatedEntry.id) throw new Error('Entry id mismatch'); let operations = jsonPatch.compare(withoutSenses(initialEntry), withoutSenses(updatedEntry)); if (operations.length == 0) return; - await lexboxApi.UpdateEntry(updatedEntry.id, operations); + await saveHandler(() => lexboxApi.UpdateEntry(updatedEntry.id, operations)); } async function updateSense(updatedSense: ISense) { if (isEmptyId(updatedSense.id)) { updatedSense.id = crypto.randomUUID(); - await lexboxApi.CreateSense(entry.id, updatedSense); + await saveHandler(() => lexboxApi.CreateSense(entry.id, updatedSense)); return; } const initialSense = initialEntry.senses.find(s => s.id === updatedSense.id); if (!initialSense) throw new Error('Sense not found in initial entry'); let operations = jsonPatch.compare(withoutExamples(initialSense), withoutExamples(updatedSense)); if (operations.length == 0) return; - await lexboxApi.UpdateSense(entry.id, updatedSense.id, operations); + await saveHandler(() => lexboxApi.UpdateSense(entry.id, updatedSense.id, operations)); } async function updateExample(senseId: string, updatedExample: IExampleSentence) { @@ -84,14 +86,14 @@ if (!initialSense) throw new Error('Sense not found in initial entry'); if (isEmptyId(updatedExample.id)) { updatedExample.id = crypto.randomUUID(); - await lexboxApi.CreateExampleSentence(entry.id, senseId, updatedExample); + await saveHandler(() => lexboxApi.CreateExampleSentence(entry.id, senseId, updatedExample)); return; } const initialExample = initialSense.exampleSentences.find(e => e.id === updatedExample.id); if (!initialExample) throw new Error('Example not found in initial sense'); let operations = jsonPatch.compare(initialExample, updatedExample); if (operations.length == 0) return; - await lexboxApi.UpdateExampleSentence(entry.id, senseId, updatedExample.id, operations); + await saveHandler(() => lexboxApi.UpdateExampleSentence(entry.id, senseId, updatedExample.id, operations)); } diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index 51e62762b..2c1a4a223 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -6,7 +6,8 @@ import {useLexboxApi} from '../services/service-provider'; import { mdiBookPlusOutline } from '@mdi/js'; import { defaultEntry } from '../utils'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, getContext } from 'svelte'; + import type { SaveHandler } from '../services/save-event-service'; const dispatch = createEventDispatcher<{ created: { entry: IEntry}; @@ -16,11 +17,12 @@ let entry: IEntry = defaultEntry(); const lexboxApi = useLexboxApi(); + const saveHandler = getContext('saveHandler'); async function createEntry(e: Event, closeDialog: () => void) { e.preventDefault(); loading = true; - await lexboxApi.CreateEntry(entry); + await saveHandler(() => lexboxApi.CreateEntry(entry)); dispatch('created', {entry}); loading = false; closeDialog(); diff --git a/frontend/viewer/src/lib/services/save-event-service.ts b/frontend/viewer/src/lib/services/save-event-service.ts new file mode 100644 index 000000000..717ccd85e --- /dev/null +++ b/frontend/viewer/src/lib/services/save-event-service.ts @@ -0,0 +1,19 @@ +import { writable, type Readable, type Writable } from "svelte/store"; + +export type SaveEvent = { saving: true } | { saved: true } | { status: 'saved-to-disk' | 'failed-to-save' }; +export type SaveEventEmmiter = Readable; +type SaveEventDispatcher = Writable; +export type SaveHandler = (saveAction: () => Promise) => Promise; +export const saveEventDispatcher: SaveEventDispatcher = writable({ status: 'saved-to-disk'}); +export const saveHandler: SaveHandler = async (saveAction: () => Promise): Promise => { + saveEventDispatcher.set({ saving: true }); + try { + const result = await saveAction(); + throw ''; + saveEventDispatcher.set({ saved: true }); + return result; + } catch (e) { + saveEventDispatcher.set({ status: 'failed-to-save' }); + throw e; + } +}; diff --git a/frontend/viewer/src/lib/status/SaveStatus.svelte b/frontend/viewer/src/lib/status/SaveStatus.svelte new file mode 100644 index 000000000..e2ed66b2a --- /dev/null +++ b/frontend/viewer/src/lib/status/SaveStatus.svelte @@ -0,0 +1,115 @@ + + + +
+
+ +
+ {#if lastSaved} + {@const savedAgo = Date.now() - lastSaved.getTime()} + Last saved + {#if savedAgo < 30_000} + a few seconds + {:else if savedAgo < 90_000} + a minute + {:else} + {humanizeDuration({ duration: { milliseconds: savedAgo }, minUnits: DurationUnits.Minute })} + {/if} + ago + {:else if lastStatus === 'saved-to-disk'} + + Saved to disk + + + {:else if lastStatus === 'failed-to-save'} + Save failed + {/if} +
+
+ +
+
+
diff --git a/frontend/viewer/tailwind.config.cjs b/frontend/viewer/tailwind.config.cjs index 8c375c5e8..a9ae479d5 100644 --- a/frontend/viewer/tailwind.config.cjs +++ b/frontend/viewer/tailwind.config.cjs @@ -22,13 +22,14 @@ module.exports = { "color-scheme": "light", "--base-text": "#394E6A", "primary": "#0050CC", - "secondary": "#A8C8FF", + "secondary": "#D6E6FF", "accent": "#b4e9d6", "neutral": "#70acc7", "surface-100": "oklch(100% 0 0)", "surface-200": "#f4f5f6", "surface-300": "#d1d5db", "surface-content": "#394E6A", + "info": "#0E94FF", "warning": "#ff6c00", "--rounded-btn": "1.9rem", "--tab-radius": "0.7rem", From 86af499bc658f63d5b1d2232fd9989a9dce42d64 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 16:57:58 +0200 Subject: [PATCH 29/38] Layout and mobile fixes --- frontend/viewer/src/ProjectView.svelte | 48 ++++++++++--------- .../viewer/src/lib/history/HistoryView.svelte | 2 +- .../src/lib/search-bar/SearchBar.svelte | 9 ++-- .../viewer/src/lib/status/SaveStatus.svelte | 28 ++++++----- 4 files changed, 46 insertions(+), 41 deletions(-) diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index 073d8fe9d..d935fb395 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -229,10 +229,10 @@ {:else}
-
+
-
+
@@ -264,10 +264,10 @@ class="grid flex-grow items-start justify-stretch md:justify-center" style="grid-template-columns: minmax(0, min-content) minmax(0, min-content) minmax(0, min-content);" > -
+
pickedEntry = true} />
-
+
{#if $selectedEntry}
@@ -290,31 +290,33 @@
{/if}
-
+
-
- {#if $selectedEntry && !expandList} -
- {#if !$viewConfig.readonly} -
- +
+ {#if $selectedEntry} +
+
+ {#if !$viewConfig.readonly} +
+ +
+ {/if} +
+
- {/if} -
-
+ + {$viewConfig.activeView.label} +
- - {$viewConfig.activeView.label} -
diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index 37ceaecad..5d154f99d 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -52,7 +52,7 @@ } -
From 487d5ef2970fbc1e4f81a94395149a0ad322c7ea Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 17:28:47 +0200 Subject: [PATCH 30/38] Cheap fix for semantic domain list being way to big --- frontend/viewer/src/app.postcss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss index 792001892..83d8c9384 100644 --- a/frontend/viewer/src/app.postcss +++ b/frontend/viewer/src/app.postcss @@ -73,3 +73,7 @@ @apply border border-surface-content rounded-md shadow-md; } } + +.Popover .menu-items { + max-height: 40vh; +} From f376895aaba0c729da63037c358e813e5f048b83 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 21 Jun 2024 17:30:34 +0200 Subject: [PATCH 31/38] Format selected semantic domains --- .../viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte b/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte index df324bd3e..c40214202 100644 --- a/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte +++ b/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte @@ -36,6 +36,8 @@ {options} valueProp="value" labelProp="label" + formatSelected={({ options }) => + options.map((o) => o.label).join(", ") || "None"} infiniteScroll clearSearchOnOpen={false} clearable={false} From db913e801bfe76480068408693869946df0d9a0e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 1 Jul 2024 15:59:39 +0700 Subject: [PATCH 32/38] gracefully handle null order by text --- backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 4fa2e608c..ea8f9091a 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -184,9 +184,9 @@ public async IAsyncEnumerable GetSemanticDomains() } } - internal ICmSemanticDomain GetLcmSemanticDomain(SemanticDomain semanticDomain) + internal ICmSemanticDomain GetLcmSemanticDomain(Guid semanticDomainId) { - return _semanticDomainRepository.GetObject(semanticDomain.Id); + return _semanticDomainRepository.GetObject(semanticDomainId); } private Entry FromLexEntry(ILexEntry entry) @@ -279,7 +279,13 @@ public async IAsyncEnumerable GetEntries( } var sortWs = GetWritingSystemHandle(options.Order.WritingSystem, WritingSystemType.Vernacular); - entries = entries.OrderBy(e => (e.CitationForm.get_String(sortWs).Text ?? e.LexemeFormOA.Form.get_String(sortWs).Text).Trim(LcmHelpers.WhitespaceChars)) + entries = entries + .OrderBy(e => + { + string? text = e.CitationForm.get_String(sortWs).Text; + text ??= e.LexemeFormOA.Form.get_String(sortWs).Text; + return text?.Trim(LcmHelpers.WhitespaceChars); + }) .Skip(options.Offset) .Take(options.Count); From 23add28d0ec34b0544ebe79a94145eef7e5184ca Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 1 Jul 2024 16:09:51 +0700 Subject: [PATCH 33/38] rework CrdtMultiOptionField to support editing object lists --- .../Api/UpdateProxy/UpdateSenseProxy.cs | 25 +++++++++++++++---- frontend/viewer/src/ProjectView.svelte | 2 +- frontend/viewer/src/lib/config-types.ts | 2 ++ .../entry-editor/CrdtMultiOptionField.svelte | 22 ++++++++-------- .../src/lib/entry-editor/FieldEditor.svelte | 4 +-- .../lib/entry-editor/MultiOptionEditor.svelte | 4 +-- .../viewer/src/lib/sandbox/Sandbox.svelte | 24 +++++++++++++++--- .../src/lib/services/save-event-service.ts | 1 - 8 files changed, 57 insertions(+), 27 deletions(-) diff --git a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs index d4690ffd5..155de8fc6 100644 --- a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs @@ -55,17 +55,32 @@ public override Guid? PartOfSpeechId } } - public override IList SemanticDomains + //the frontend may sometimes try to issue patches to remove Domain.Code or Name, but all we care about is Id + //when those cases happen then Id will be default, so we ignore them. + public new IList SemanticDomains { - get => new UpdateListProxy( - semanticDomain => sense.SemanticDomainsRC.Add(lexboxLcmApi.GetLcmSemanticDomain(semanticDomain)), - semanticDomain => sense.SemanticDomainsRC.Remove(sense.SemanticDomainsRC.First(sd => sd.Guid == semanticDomain.Id)), - i => new SemanticDomain { Id = sense.SemanticDomainsRC.ElementAt(i).Guid, Code = "", Name = new MultiString() }, + get => new UpdateListProxy( + semanticDomain => + { + if (semanticDomain.Id != default) sense.SemanticDomainsRC.Add(lexboxLcmApi.GetLcmSemanticDomain(semanticDomain.Id)); + }, + semanticDomain => + { + if (semanticDomain.Id != default) sense.SemanticDomainsRC.Remove(sense.SemanticDomainsRC.First(sd => sd.Guid == semanticDomain.Id)); + }, + i => new UpdateProxySemanticDomain { Id = sense.SemanticDomainsRC.ElementAt(i).Guid }, sense.SemanticDomainsRC.Count ); set => throw new NotImplementedException(); } + public class UpdateProxySemanticDomain + { + public Guid Id { get; set; } + public string? Code { get; set; } + public MultiString? Name { get; set; } + } + public override IList ExampleSentences { get => diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index d935fb395..1ae31435d 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -89,7 +89,7 @@ }); const optionProvider: OptionProvider = { partsOfSpeech: derived([writingSystems, partsOfSpeech], ([ws, pos]) => pos?.map(option => ({ value: option.id, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), - semanticDomains: derived([writingSystems, semanticDomains], ([ws, sd]) => sd?.map(option => ({ value: option, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), + semanticDomains: derived([writingSystems, semanticDomains], ([ws, sd]) => sd?.map(option => ({ value: option.id, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), }; setContext('optionProvider', optionProvider); diff --git a/frontend/viewer/src/lib/config-types.ts b/frontend/viewer/src/lib/config-types.ts index 3b99ef7a9..2498e028d 100644 --- a/frontend/viewer/src/lib/config-types.ts +++ b/frontend/viewer/src/lib/config-types.ts @@ -27,6 +27,8 @@ export type OptionFieldConfig = { ws: `first-${WritingSystemType}`; } +export type OptionFieldValue = {id: string}; + export type BaseEntityFieldConfig = (({ type: 'multi'; id: ConditionalKeys; diff --git a/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte b/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte index c40214202..837a62d47 100644 --- a/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte +++ b/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte @@ -2,8 +2,9 @@ import type { ComponentProps } from 'svelte'; import CrdtField from './CrdtField.svelte'; import { TextField, type MenuOption, MultiSelectField } from 'svelte-ux'; + import type {OptionFieldValue} from '../config-types'; - export let value: any[]; + export let value: OptionFieldValue[]; export let unsavedChanges = false; export let options: MenuOption[] = []; @@ -13,25 +14,21 @@ export let readonly: boolean | undefined = undefined; let append: HTMLElement; - function asOption(value: any): MenuOption { - if (!(typeof value === 'object' && 'label' in value && 'value' in value)) { - throw new Error('Invalid option'); - } - return value; - } - function asOptions(values: any[]): MenuOption[] { - return values?.map(asOption) ?? []; + function asMultiSelectValues(values: any[]): string[] { + return values?.map(v => v.id) ?? []; + } + function asObjectValues(values: string[]) { + return values.map(v => ({id: v})); } { - console.log(e); - onEditorValueChange(e.detail.value, true); + onEditorValueChange(asObjectValues(e.detail.value), true); }} - value={editorValue} + value={asMultiSelectValues(editorValue)} disabled={readonly} {options} valueProp="value" @@ -49,6 +46,7 @@ +{@debug value}