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..bc449a22c --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs @@ -0,0 +1,34 @@ +using FwDataMiniLcmBridge.Api; +using Microsoft.Extensions.DependencyInjection; + +namespace FwDataMiniLcmBridge.Tests.Fixtures; + +public class ProjectLoaderFixture : IDisposable +{ + public const string Name = "ProjectLoaderCollection"; + private readonly FwDataFactory _fwDataFactory; + private readonly 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() + { + _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 new file mode 100644 index 000000000..9ce1673c5 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -0,0 +1,34 @@ + + + + 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..532d16bf9 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/PartOfSpeechTests.cs @@ -0,0 +1,47 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; +using MiniLcm; + +namespace FwDataMiniLcmBridge.Tests; + +[Collection(ProjectLoaderFixture.Name)] +public class PartOfSpeechTests(ProjectLoaderFixture 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..b0f1945e6 --- /dev/null +++ b/backend/FwDataMiniLcmBridge.Tests/SemanticDomainTests.cs @@ -0,0 +1,75 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; +using MiniLcm; + +namespace FwDataMiniLcmBridge.Tests; + +[Collection(ProjectLoaderFixture.Name)] +public class SemanticDomainTests(ProjectLoaderFixture fixture) +{ + [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.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()); + } +} diff --git a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 2f862442c..e86b5931a 100644 --- a/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1,4 +1,6 @@ -using FwDataMiniLcmBridge.Api.UpdateProxy; +using System.Collections.Frozen; +using System.Text; +using FwDataMiniLcmBridge.Api.UpdateProxy; using Microsoft.Extensions.Logging; using MiniLcm; using SIL.LCModel; @@ -34,6 +36,10 @@ public class FwDataMiniLcmApi(LcmCache cache, bool onCloseSave, ILogger(); + private readonly IPartOfSpeechRepository _partOfSpeechRepository = + cache.ServiceLocator.GetInstance(); + private readonly ICmSemanticDomainRepository _semanticDomainRepository = + cache.ServiceLocator.GetInstance(); private readonly ICmTranslationFactory _cmTranslationFactory = cache.ServiceLocator.GetInstance(); @@ -133,8 +139,8 @@ internal void CompleteExemplars(WritingSystems writingSystems) { var wsExemplars = writingSystems.Vernacular.Concat(writingSystems.Analysis) .Distinct() - .ToDictionary(ws => ws, ws => ws.Exemplars.ToHashSet()); - var wsExemplarsByHandle = wsExemplars.ToDictionary(kv => GetWritingSystemHandle(kv.Key.Id), kv => kv.Value); + .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()) { @@ -144,7 +150,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())]; } } @@ -158,6 +164,32 @@ public Task UpdateWritingSystem(WritingSystemId id, WritingSystem throw new NotImplementedException(); } + public async IAsyncEnumerable GetPartsOfSpeech() + { + foreach (var partOfSpeech in _partOfSpeechRepository.AllInstances().OrderBy(p => p.Name.BestAnalysisAlternative.Text)) + { + yield return new PartOfSpeech { Id = partOfSpeech.Guid, Name = FromLcmMultiString(partOfSpeech.Name) }; + } + } + + public async IAsyncEnumerable GetSemanticDomains() + { + foreach (var semanticDomain in _semanticDomainRepository.AllInstances().OrderBy(p => p.Name.BestAnalysisAlternative.Text)) + { + yield return new SemanticDomain + { + Id = semanticDomain.Guid, + Name = FromLcmMultiString(semanticDomain.Name), + Code = semanticDomain.OcmCodes + }; + } + } + + internal ICmSemanticDomain GetLcmSemanticDomain(Guid semanticDomainId) + { + return _semanticDomainRepository.GetObject(semanticDomainId); + } + 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?.PosFieldName ?? "", + 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) @@ -198,7 +237,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); @@ -208,32 +247,43 @@ 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(); options ??= QueryOptions.Default; - if (predicate is not null) entries = entries.Where(e => predicate(e)); + if (predicate is not null) entries = entries.Where(predicate); 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); + var exemplar = options.Exemplar.Value.Normalize(NormalizationForm.FormD);//LCM data is NFD so the should be as well + entries = entries.Where(e => + { + var value = (e.CitationForm.get_String(ws).Text ?? e.LexemeFormOA.Form.get_String(ws).Text)? + .Trim(LcmHelpers.WhitespaceAndFormattingChars); + if (value is null || value.Length < exemplar.Length) return false; + //exemplar is normalized, so we can use StartsWith + //there may still be cases where value.StartsWith(value[0].ToString()) == false (e.g. "آبراهام") + //but I don't have the data to test that + return value.StartsWith(exemplar, StringComparison.InvariantCultureIgnoreCase); + }); } 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); diff --git a/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs b/backend/FwDataMiniLcmBridge/Api/LcmHelpers.cs index 351022e8c..654338244 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; } @@ -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, IReadOnlyDictionary> 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(char.ToUpperInvariant(value[0])); } } } diff --git a/backend/FwDataMiniLcmBridge/Api/MorphoSyntaxExtensions.cs b/backend/FwDataMiniLcmBridge/Api/MorphoSyntaxExtensions.cs new file mode 100644 index 000000000..6480fcd3e --- /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..b88b25e0b 100644 --- a/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs +++ b/backend/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateListProxy.cs @@ -5,8 +5,10 @@ namespace FwDataMiniLcmBridge.Api.UpdateProxy; public class UpdateListProxy( Action add, Action remove, - Func getAt) : IList + Func getAt, + int count) : IList, IList { + public IEnumerator GetEnumerator() { throw new NotImplementedException(); @@ -22,11 +24,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 +74,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 => false; + + 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 +107,8 @@ public void RemoveAt(int index) Remove(getAt(index)); } + bool IList.IsFixedSize => false; + public T this[int index] { get => getAt(index); @@ -71,4 +118,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..6789eddf9 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,23 +27,69 @@ 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.SetMsaPartOfSpeech(null); + } + } + } + + //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 => + { + 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 => 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..daa0db55a 100644 --- a/backend/LcmCrdt/CrdtLexboxApi.cs +++ b/backend/LcmCrdt/CrdtLexboxApi.cs @@ -7,6 +7,7 @@ using MiniLcm; using LinqToDB; using LinqToDB.EntityFrameworkCore; +using SemanticDomain = LcmCrdt.Objects.SemanticDomain; namespace LcmCrdt; @@ -19,6 +20,8 @@ public class CrdtLexboxApi(DataModel dataModel, JsonSerializerOptions jsonOption private IQueryable Senses => dataModel.GetLatestObjects(); private IQueryable ExampleSentences => dataModel.GetLatestObjects(); private IQueryable WritingSystems => dataModel.GetLatestObjects(); + private IQueryable SemanticDomains => dataModel.GetLatestObjects(); + private IQueryable PartsOfSpeech => dataModel.GetLatestObjects(); public async Task GetWritingSystems() { @@ -65,6 +68,16 @@ public async Task GetWritingSystems() return await WritingSystems.FirstOrDefaultAsync(ws => ws.WsId == id && ws.Type == type); } + public IAsyncEnumerable GetPartsOfSpeech() + { + return PartsOfSpeech.AsAsyncEnumerable(); + } + + public IAsyncEnumerable GetSemanticDomains() + { + return SemanticDomains.AsAsyncEnumerable(); + } + public IAsyncEnumerable GetEntries(QueryOptions? options = null) { return GetEntriesAsyncEnum(predicate: null, options); @@ -180,9 +193,9 @@ await dataModel.AddChanges(ClientId, await dataModel.AddChanges(ClientId, [ new CreateEntryChange(entry), - ..entry.Senses.Select(s => new CreateSenseChange(s, entry.Id)), - ..entry.Senses.SelectMany(s => s.ExampleSentences, - (sense, sentence) => new CreateExampleSentenceChange(sentence, sense.Id)) + ..await entry.Senses.ToAsyncEnumerable() + .SelectMany(s => CreateSenseChanges(entry.Id, s)) + .ToArrayAsync() ]); return await GetEntry(entry.Id) ?? throw new NullReferenceException(); } @@ -200,14 +213,31 @@ public async Task DeleteEntry(Guid id) await dataModel.AddChange(ClientId, new DeleteChange(id)); } + private async IAsyncEnumerable CreateSenseChanges(Guid entryId, MiniLcm.Sense sense) + { + sense.SemanticDomains = await SemanticDomains + .Where(sd => sense.SemanticDomains.Select(s => s.Id).Contains(sd.Id)) + .OfType() + .ToListAsync(); + if (sense.PartOfSpeechId is not null) + { + var partOfSpeech = await PartsOfSpeech.FirstOrDefaultAsync(p => p.Id == sense.PartOfSpeechId); + sense.PartOfSpeechId = partOfSpeech?.Id; + sense.PartOfSpeech = partOfSpeech?.Name["en"] ?? string.Empty; + } + + + yield return new CreateSenseChange(sense, entryId); + foreach (var change in sense.ExampleSentences.Select(sentence => + new CreateExampleSentenceChange(sentence, sense.Id))) + { + yield return change; + } + } + public async Task CreateSense(Guid entryId, MiniLcm.Sense sense) { - await dataModel.AddChanges(ClientId, - [ - new CreateSenseChange(sense, entryId), - ..sense.ExampleSentences.Select(sentence => - new CreateExampleSentenceChange(sentence, sense.Id)) - ]); + await dataModel.AddChanges(ClientId, await CreateSenseChanges(entryId, sense).ToArrayAsync()); return await dataModel.GetLatest(sense.Id) ?? throw new NullReferenceException(); } @@ -215,8 +245,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 +284,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/Entities/Entry.cs b/backend/LfClassicData/Entities/Entry.cs index 1ba11dc3b..d9c8c2411 100644 --- a/backend/LfClassicData/Entities/Entry.cs +++ b/backend/LfClassicData/Entities/Entry.cs @@ -35,3 +35,16 @@ public class Example public Dictionary? Reference { get; set; } } + +public class OptionListRecord +{ + public required string Code { get; set; } + public List Items { get; set; } = []; +} +public class OptionListItem +{ + public Guid? Guid { get; set; } + public string? Key { get; set; } + public string? Value { get; set; } + public string? Abbreviation { get; set; } +} diff --git a/backend/LfClassicData/LfClassicLexboxApi.cs b/backend/LfClassicData/LfClassicLexboxApi.cs index 1fc8050e0..b1ce36a04 100644 --- a/backend/LfClassicData/LfClassicLexboxApi.cs +++ b/backend/LfClassicData/LfClassicLexboxApi.cs @@ -45,6 +45,25 @@ public async Task GetWritingSystems() }; } + public async IAsyncEnumerable GetPartsOfSpeech() + { + var optionListItems = await dbContext.GetOptionListItems(projectCode, "grammatical-info"); + + foreach (var item in optionListItems) + { + yield return new PartOfSpeech + { + Id = item.Guid ?? Guid.Empty, + Name = new MultiString { { "en", item.Value ?? item.Abbreviation ?? string.Empty } } + }; + } + } + + public IAsyncEnumerable GetSemanticDomains() + { + return AsyncEnumerable.Empty(); + } + public Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem) { throw new NotSupportedException(); @@ -122,7 +141,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/LfClassicData/LfClassicRoutes.cs b/backend/LfClassicData/LfClassicRoutes.cs index c52ee01fc..02480dabd 100644 --- a/backend/LfClassicData/LfClassicRoutes.cs +++ b/backend/LfClassicData/LfClassicRoutes.cs @@ -42,6 +42,18 @@ [AsParameters] ClassicQueryOptions options var api = provider.GetProjectApi(projectCode); return api.GetEntry(id); }); + group.MapGet("/parts-of-speech", + ([FromRoute] string projectCode, [FromServices] ILexboxApiProvider provider) => + { + var api = provider.GetProjectApi(projectCode); + return api.GetPartsOfSpeech(); + }); + group.MapGet("/semantic-domains", + ([FromRoute] string projectCode, [FromServices] ILexboxApiProvider provider) => + { + var api = provider.GetProjectApi(projectCode); + return api.GetSemanticDomains(); + }); return group; } diff --git a/backend/LfClassicData/ProjectDbContext.cs b/backend/LfClassicData/ProjectDbContext.cs index 8f8141224..be80ec239 100644 --- a/backend/LfClassicData/ProjectDbContext.cs +++ b/backend/LfClassicData/ProjectDbContext.cs @@ -21,4 +21,12 @@ public IMongoCollection Entries(string projectCode) { return GetCollection(projectCode, "lexicon"); } + + public async Task GetOptionListItems(string projectCode, string listCode) + { + var collection = GetCollection(projectCode, "optionlists"); + var result = await collection.Find(e => e.Code == listCode).FirstOrDefaultAsync(); + if (result is null) return []; + return [..result.Items]; + } } 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/backend/LocalWebApp/LocalWebApp.csproj b/backend/LocalWebApp/LocalWebApp.csproj index f103c49ab..9f2283f1b 100644 --- a/backend/LocalWebApp/LocalWebApp.csproj +++ b/backend/LocalWebApp/LocalWebApp.csproj @@ -6,9 +6,11 @@ enable Linux true + false - true + + false true false 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..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() { } @@ -76,6 +84,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; } = []; } diff --git a/backend/Taskfile.yml b/backend/Taskfile.yml index c0e4637ae..591b295f4 100644 --- a/backend/Taskfile.yml +++ b/backend/Taskfile.yml @@ -107,6 +107,7 @@ tasks: - task: publish-local-win - task: publish-local-linux - task: publish-local-osx + - task: publish-local-osx-arm publish-local-win: dir: ./LocalWebApp deps: [ui:build-viewer-app] @@ -117,3 +118,6 @@ tasks: publish-local-osx: dir: ./LocalWebApp cmd: dotnet publish -r osx-x64 + publish-local-osx-arm: + dir: ./LocalWebApp + cmd: dotnet publish -r osx-arm64 diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts b/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts index 771e1a6cd..ecd04df61 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/viewer/lfClassicLexboxApi.ts @@ -6,7 +6,9 @@ import { type WritingSystemType, type WritingSystem, type LexboxApiClient, - type LexboxApiFeatures + type LexboxApiFeatures, + type PartOfSpeech, + type SemanticDomain } from 'viewer/lexbox-api'; @@ -55,6 +57,15 @@ export class LfClassicLexboxApi implements LexboxApiClient { return '?' + params.toString(); } + async GetPartsOfSpeech(): Promise { + const result = await fetch(`/api/lfclassic/${this.projectCode}/parts-of-speech`); + return (await result.json()) as PartOfSpeech[]; + } + + GetSemanticDomains(): Promise { + return Promise.resolve([]); + } + CreateWritingSystem(_type: WritingSystemType, _writingSystem: WritingSystem): Promise { throw new Error('Method not implemented.'); } diff --git a/frontend/viewer/src/App.svelte b/frontend/viewer/src/App.svelte index cd9fe9df9..ccfca6518 100644 --- a/frontend/viewer/src/App.svelte +++ b/frontend/viewer/src/App.svelte @@ -4,6 +4,7 @@ import TestProjectView from './TestProjectView.svelte'; import FwDataProjectView from './FwDataProjectView.svelte'; import HomeView from './HomeView.svelte'; + import Sandbox from './lib/sandbox/Sandbox.svelte'; export let url = ''; @@ -29,6 +30,9 @@ + + + {setTimeout(() => navigate("/", { replace: true }))} diff --git a/frontend/viewer/src/HomeView.svelte b/frontend/viewer/src/HomeView.svelte index dcf49bf7e..34ef4e02e 100644 --- a/frontend/viewer/src/HomeView.svelte +++ b/frontend/viewer/src/HomeView.svelte @@ -6,13 +6,14 @@ mdiBookEditOutline, mdiBookPlusOutline, mdiBookSyncOutline, - mdiTestTube + mdiTestTube, } from '@mdi/js'; - import {navigate} from 'svelte-routing'; - import {Button, Card, type ColumnDef, ListItem, Table, TextField, tableCell, Icon} from 'svelte-ux'; + import { navigate } from 'svelte-routing'; + import { Button, Card, type ColumnDef, ListItem, Table, TextField, tableCell, Icon } from 'svelte-ux'; import flexLogo from './lib/assets/flex-logo.png'; + import DevContent, { isDev } from './lib/layout/DevContent.svelte'; - type Project = { name: string, crdt: boolean, fwdata: boolean, lexbox: boolean }; + type Project = { name: string; crdt: boolean; fwdata: boolean; lexbox: boolean }; let newProjectName = ''; let projectsPromise = fetchProjects(); @@ -22,11 +23,11 @@ createError = ''; if (!newProjectName) { - createError = 'Project name is required.' + createError = 'Project name is required.'; return; } const response = await fetch(`/api/project?name=${newProjectName}`, { - method: 'POST' + method: 'POST', }); if (!response.ok) { @@ -44,7 +45,7 @@ async function importFwDataProject(name: string) { loading = name; await fetch(`/api/import/fwdata/${name}`, { - method: 'POST' + method: 'POST', }); projectsPromise = fetchProjects(); await projectsPromise; @@ -55,7 +56,7 @@ async function downloadCrdtProject(name: string) { downloading = name; - await fetch(`/api/download/crdt/${name}`, {method: 'POST'}); + await fetch(`/api/download/crdt/${name}`, { method: 'POST' }); projectsPromise = fetchProjects(); await projectsPromise; downloading = ''; @@ -64,7 +65,7 @@ let uploading = ''; async function uploadCrdtProject(name: string) { uploading = name; - await fetch(`/api/upload/crdt/${name}`, {method: 'POST'}); + await fetch(`/api/upload/crdt/${name}`, { method: 'POST' }); projectsPromise = fetchProjects(); await projectsPromise; uploading = ''; @@ -72,7 +73,7 @@ async function fetchProjects() { let r = await fetch('/api/projects'); - return await r.json() as Promise; + return (await r.json()) as Promise; } let username = ''; @@ -85,134 +86,158 @@ fetchMe(); - $: columns = [ { - name: 'name' + name: 'name', + header: 'Name', }, { name: 'fwdata', - header: 'FieldWorks' - }, - { - name: 'crdt', - header: 'CRDT' + header: 'FieldWorks', }, - ...(loggedIn ? [{ - name: 'lexbox', - header: 'Lexbox CRDT', - }] : []), + ...($isDev + ? [ + { + name: 'crdt', + header: 'CRDT', + }, + ] + : []), + ...(loggedIn + ? [ + { + name: 'lexbox', + header: 'Lexbox CRDT', + }, + ] + : []), ] satisfies ColumnDef[]; + + +
+ +
+ + + + + + {#if loggedIn} +

{username}

+ + {:else} + + {/if} +
+
+
+
+
+
+
My projects
+ +
+ {#await projectsPromise} +

loading...

+ {:then projects} + $isDev || p.fwdata).sort((p1, p2) => p1.name.localeCompare(p2.name))} classes={{ th: 'p-4' }}> + + {#each data ?? [] as rowData, rowIndex} + + {#each columns as column (column.name)} + + {/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} +
+ Test project + + +
+ {/await} +
+
+
+
+
+
+ -
-
- - - - - - {#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')}/> -
-
- -
diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index d8ea67ca4..1ae31435d 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -2,13 +2,13 @@ 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 {onMount, setContext} from 'svelte'; import {derived, writable, type Readable} from 'svelte/store'; - import {deriveAsync} from './lib/utils/time'; + import {deriveAsync, makeDebouncer} from './lib/utils/time'; import {type ViewConfig, type LexboxPermissions, type ViewOptions, type LexboxFeatures} from './lib/config-types'; import ViewOptionsDrawer from './lib/layout/ViewOptionsDrawer.svelte'; import EntryList from './lib/layout/EntryList.svelte'; @@ -18,12 +18,19 @@ 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'; + 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, @@ -53,13 +60,15 @@ $: 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 writingSystems = deriveAsync(connected, isConnected => { + const { value: writingSystems } = deriveAsync(connected, isConnected => { if (!isConnected) return Promise.resolve(null); return lexboxApi.GetWritingSystems(); }); @@ -69,24 +78,41 @@ }); setContext('indexExamplars', indexExamplars); const trigger = writable(0); + + const { value: partsOfSpeech } = deriveAsync(connected, isConnected => { + if (!isConnected) return Promise.resolve(null); + return lexboxApi.GetPartsOfSpeech(); + }); + const { value: 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.id, label: pickBestAlternative(option.name, ws?.analysis[0]) })) ?? []), + }; + setContext('optionProvider', optionProvider); + + 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); } - const _entries = deriveAsync(derived([search, connected, selectedIndexExemplar, trigger], s => s), ([s, isConnected, exemplar]) => { - return fetchEntries(s, isConnected, exemplar); - }, undefined, 200); - // 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 // 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 @@ -97,8 +123,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; @@ -107,19 +142,33 @@ //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]; + } - $: _loading = !$entries || !$writingSystems || loading; + updateEntryIdSearchParam(); + navigateToEntryIdOnLoad = undefined; + } + + $: _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 @@ -140,29 +189,58 @@ let expandList = false; let collapseActionBar = false; - let pickedEntry = false; let entryActionsElem: HTMLDivElement; const entryActionsPortal = writable<{target: HTMLDivElement, collapsed: boolean}>(); setContext('entryActionsPortal', entryActionsPortal); $: entryActionsPortal.set({target: entryActionsElem, collapsed: collapseActionBar}); + let editorElem: HTMLElement | undefined; + let spaceForEditorStyle: string = ''; + const updateSpaceForEditor = makeDebouncer(() => { + if (!editorElem) return; + const availableHeight = getAvailableHeightForElement(editorElem); + spaceForEditorStyle = `--space-for-editor: ${availableHeight}px`; + }, 30).debounce; + + $: editorElem && updateSpaceForEditor(); + onMount(() => { + const abortController = new AbortController(); + window.addEventListener('resize', updateSpaceForEditor, abortController); + window.addEventListener('scroll', updateSpaceForEditor, abortController); + return () => { + abortController.abort(); + unsubSelectedEntry(); + }; + }); {projectName} -
- -
+ +{#if _loading || !$entries} +
+
+ Loading {projectName}... +
+
+{:else} +
+ +
+
+ +
+
navigateToEntry(e.detail)} />
-
+
{#if !$viewConfig.readonly} onEntryCreated(e.detail.entry)} /> @@ -181,51 +259,44 @@ {/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 !$viewConfig.readonly} - onEntryCreated(e.detail.entry)}/> - {/if} -
- {/if} -
-
- -
- {#if $selectedEntry && !expandList} +
{#if !$viewConfig.readonly}
@@ -236,7 +307,7 @@
- + {$viewConfig.activeView.label}
+
+ {/if}
-
- - +
+ - {/if} +
+{/if} diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss index 019aa402e..83d8c9384 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(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; @@ -63,4 +64,16 @@ .text-field-sibling-button { @apply h-[37.6px] p-1.5 aspect-square text-[0.9em] text-surface-content; } + + .key { + display: inline-block; + padding: 0.15em 0.4em; + margin: 0 0.1em; + font-size: 0.8em; + @apply border border-surface-content rounded-md shadow-md; + } +} + +.Popover .menu-items { + max-height: 40vh; } 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/config-data.ts b/frontend/viewer/src/lib/config-data.ts index 96403c001..a2acedf0a 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: { @@ -11,16 +12,16 @@ const allFieldConfigs = ({ note: { id: 'note', type: 'multi', ws: 'analysis', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Entry_level_fields/Note_field.htm' }, }, customEntry: { - custom1: { id: 'entry-custom-001', type: 'multi', ws: 'vernacular', name: 'Custom 1', custom: true }, + // custom1: { id: 'entry-custom-001', type: 'multi', ws: 'vernacular', name: 'Custom 1', custom: true }, }, 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 }, + // custom1: { id: 'sense-custom-001', type: 'multi', ws: 'first-analysis', name: 'Custom sense', custom: true }, }, example: { sentence: { id: 'sentence', type: 'multi', ws: 'vernacular', helpId: 'User_Interface/Field_Descriptions/Lexicon/Lexicon_Edit_fields/Sense_level_fields/example_field.htm' }, @@ -38,15 +39,20 @@ const allFieldConfigs = ({ customExample: Record, }; -export function allFields(viewConfig: ViewConfig): FieldConfig[] { +type FieldOptionType = T['optionType']; +type OptionFields = ValueOf>>; +export type WellKnownSingleOptionType = FieldOptionType; +export type WellKnownMultiOptionType = FieldOptionType; + +export function allFields(viewConfig: ViewConfig): Readonly { return [ ...Object.values(viewConfig.entry), - ...Object.values(viewConfig.customEntry ?? {}), + ...Object.values(viewConfig.customEntry ?? {}), ...Object.values(viewConfig.sense), - ...Object.values(viewConfig.customSense ?? {}), + ...Object.values(viewConfig.customSense ?? {}), ...Object.values(viewConfig.example), ...Object.values(viewConfig.customExample ?? {}), - ]; + ] as const; } type FieldsWithViewConfigProps>> = @@ -88,8 +94,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 +112,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..2498e028d 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,14 @@ export type CustomFieldConfig = BaseFieldConfig & { custom: true; } +export type OptionFieldConfig = { + type: `option`; + optionType: string; + ws: `first-${WritingSystemType}`; +} + +export type OptionFieldValue = {id: string}; + export type BaseEntityFieldConfig = (({ type: 'multi'; id: ConditionalKeys; @@ -28,15 +36,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/CrdtField.svelte b/frontend/viewer/src/lib/entry-editor/CrdtField.svelte index 1a14357fb..ced43c3fc 100644 --- a/frontend/viewer/src/lib/entry-editor/CrdtField.svelte +++ b/frontend/viewer/src/lib/entry-editor/CrdtField.svelte @@ -24,11 +24,13 @@ } } + type Value = $$Generic; + const dispatch = createEventDispatcher<{ - change: { value: string }; + change: { value: Value }; }>(); - export let value: string; + export let value: Value; export let viewMergeButtonPortal: HTMLElement; let editorValue = value; @@ -57,14 +59,16 @@ } function saveChanges(): void { - value = editorValue; + if (unsavedChanges) { + value = editorValue; + dispatch('change', { value }); + } unsavedChanges = false; unacceptedChanges = false; - dispatch('change', { value }); } - function onEditorValueChange(newValue: string | number, save = false): void { - editorValue = String(newValue); + function onEditorValueChange(newValue: Value, save = false): void { + editorValue = newValue; unsavedChanges = editorValue !== value; if (save) { saveChanges(); diff --git a/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte b/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte index 765c82522..837a62d47 100644 --- a/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte +++ b/frontend/viewer/src/lib/entry-editor/CrdtMultiOptionField.svelte @@ -2,51 +2,42 @@ 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: OptionFieldValue[]; - export let value: string[]; - let stringValue: string; - $: { - if (!stringValue) { - stringValue = value.join(','); - } else { - value = stringValue.split(','); - } - } export let unsavedChanges = false; + export let options: MenuOption[] = []; 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 ?? [...value.map(v => ({label: v, value: v})), {label: 'Another option', value: 'Another option'}]; - function asOption(value: any): MenuOption { - if (!(typeof value === 'object' && 'label' in value && 'value' in value)) { - throw new Error('Invalid option'); - } - return value; + function asMultiSelectValues(values: any[]): string[] { + return values?.map(v => v.id) ?? []; } - - function asOptions(values: any[]): MenuOption[] { - return values?.map(asOption) ?? []; + function asObjectValues(values: string[]) { + return values.map(v => ({id: v})); } - + onEditorValueChange(asOptions(e.detail.value).map((o) => o.value).join(','), true)} - value={editorValue.split(',')} + on:change={(e) => { + onEditorValueChange(asObjectValues(e.detail.value), true); + }} + value={asMultiSelectValues(editorValue)} disabled={readonly} - options={demoOptions ?? []} + {options} valueProp="value" labelProp="label" formatSelected={({ options }) => options.map((o) => o.label).join(", ") || "None"} + infiniteScroll clearSearchOnOpen={false} clearable={false} - search={() => Promise.resolve()} class="ws-field" classes={{ root: `${editorValue ? '' : 'empty'} ${readonly ? 'readonly' : ''}`, field: 'field-container' }} {label} @@ -55,6 +46,7 @@ +{@debug value} diff --git a/frontend/viewer/src/lib/mini-lcm/i-sense.ts b/frontend/viewer/src/lib/mini-lcm/i-sense.ts index ef8b211ae..3f52aaab7 100644 --- a/frontend/viewer/src/lib/mini-lcm/i-sense.ts +++ b/frontend/viewer/src/lib/mini-lcm/i-sense.ts @@ -5,12 +5,13 @@ import { type IMultiString } from './i-multi-string'; import { type IExampleSentence } from './i-example-sentence'; +import type { SemanticDomain } from './semantic-domain'; export interface ISense { id: string; definition: IMultiString; gloss: IMultiString; - partOfSpeech: string; - semanticDomain: string[]; + partOfSpeechId: string; + semanticDomains: SemanticDomain[]; exampleSentences: IExampleSentence[]; } diff --git a/frontend/viewer/src/lib/mini-lcm/index.ts b/frontend/viewer/src/lib/mini-lcm/index.ts index f72ee5115..c51efb880 100644 --- a/frontend/viewer/src/lib/mini-lcm/index.ts +++ b/frontend/viewer/src/lib/mini-lcm/index.ts @@ -14,3 +14,5 @@ export * from './query-options'; export * from './sense'; export * from './writing-system'; export * from './writing-systems'; +export * from './part-of-speech'; +export * from './semantic-domain'; diff --git a/frontend/viewer/src/lib/mini-lcm/part-of-speech.ts b/frontend/viewer/src/lib/mini-lcm/part-of-speech.ts new file mode 100644 index 000000000..762ed1e4e --- /dev/null +++ b/frontend/viewer/src/lib/mini-lcm/part-of-speech.ts @@ -0,0 +1,7 @@ + +import { type IMultiString } from './i-multi-string'; + +export interface PartOfSpeech { + id: string; + name: IMultiString; +} diff --git a/frontend/viewer/src/lib/mini-lcm/semantic-domain.ts b/frontend/viewer/src/lib/mini-lcm/semantic-domain.ts new file mode 100644 index 000000000..1c84de8bf --- /dev/null +++ b/frontend/viewer/src/lib/mini-lcm/semantic-domain.ts @@ -0,0 +1,8 @@ + +import { type IMultiString } from './i-multi-string'; + +export interface SemanticDomain { + id: string; + name: IMultiString; + code: string; +} diff --git a/frontend/viewer/src/lib/mini-lcm/sense.ts b/frontend/viewer/src/lib/mini-lcm/sense.ts index b1076c312..497e36462 100644 --- a/frontend/viewer/src/lib/mini-lcm/sense.ts +++ b/frontend/viewer/src/lib/mini-lcm/sense.ts @@ -6,6 +6,7 @@ import {type ISense} from './i-sense'; import {type IMultiString} from './i-multi-string'; import {type IExampleSentence} from './i-example-sentence'; +import {type SemanticDomain} from './semantic-domain'; export class Sense implements ISense { @@ -16,7 +17,7 @@ export class Sense implements ISense { id: string; definition: IMultiString = {}; gloss: IMultiString = {}; - partOfSpeech: string = ''; - semanticDomain: string[] = []; + partOfSpeechId: string = ''; + semanticDomains: SemanticDomain[] = []; exampleSentences: IExampleSentence[] = []; } diff --git a/frontend/viewer/src/lib/sandbox/Sandbox.svelte b/frontend/viewer/src/lib/sandbox/Sandbox.svelte new file mode 100644 index 000000000..2fb2894d3 --- /dev/null +++ b/frontend/viewer/src/lib/sandbox/Sandbox.svelte @@ -0,0 +1,32 @@ + + +
+ (value = e.detail.value)}/> +

selected: {value.join('|')}

+ +
+
+ +

selected: {crdtValue.map(c => c.id).join('|')}

+ +
diff --git a/frontend/viewer/src/lib/search-bar/SearchBar.svelte b/frontend/viewer/src/lib/search-bar/SearchBar.svelte index 1f8e6ff97..8791f8123 100644 --- a/frontend/viewer/src/lib/search-bar/SearchBar.svelte +++ b/frontend/viewer/src/lib/search-bar/SearchBar.svelte @@ -1,11 +1,11 @@ (showSearchDialog = true)} class="cursor-pointer opacity-80 hover:opacity-100"> -