diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs new file mode 100644 index 0000000000..a6cd01abc3 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/CanonicalMorphTypeTests.cs @@ -0,0 +1,51 @@ +using FwDataMiniLcmBridge.Api; +using FwDataMiniLcmBridge.LcmUtils; +using FwDataMiniLcmBridge.Tests.Fixtures; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using MiniLcm.Models; + +namespace FwDataMiniLcmBridge.Tests; + +public class CanonicalMorphTypeTests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly FwDataMiniLcmApi _api; + private readonly FwDataProject _project; + + public CanonicalMorphTypeTests() + { + var services = new ServiceCollection() + .AddTestFwDataBridge(mockProjectLoader: false) + .PostConfigure(config => + config.TemplatesFolder = Path.GetFullPath("Templates")) + .BuildServiceProvider(); + _serviceProvider = services; + + var config = services.GetRequiredService>(); + Directory.CreateDirectory(config.Value.ProjectsFolder); + var projectName = $"canonical-morph-types-test_{Guid.NewGuid()}"; + _project = new FwDataProject(projectName, config.Value.ProjectsFolder); + var projectLoader = services.GetRequiredService(); + projectLoader.NewProject(_project, "en", "en"); + + var fwDataFactory = services.GetRequiredService(); + _api = fwDataFactory.GetFwDataMiniLcmApi(_project, false); + } + + public void Dispose() + { + _api.Dispose(); + _serviceProvider.Dispose(); + if (Directory.Exists(_project.ProjectFolder)) + Directory.Delete(_project.ProjectFolder, true); + } + + [Fact] + public async Task CanonicalMorphTypes_MatchNewLangProjMorphTypes() + { + var libLcmMorphTypes = await _api.GetMorphTypes().ToArrayAsync(); + libLcmMorphTypes.Should().NotBeEmpty(); + CanonicalMorphTypes.All.Values.Should().BeEquivalentTo(libLcmMorphTypes); + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj index 59d1406e12..c0d6703a59 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/FwDataMiniLcmBridge.Tests.csproj @@ -35,5 +35,9 @@ + + - \ No newline at end of file + diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs index ce3eb0cc61..4f683afba5 100644 --- a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/SortingTests.cs @@ -22,9 +22,9 @@ public async Task SecondaryOrder_DefaultsToStem(string query, SortField sortFiel { var unknownMorphTypeEntryId = Guid.NewGuid(); Entry[] expected = [ - new() { Id = unknownMorphTypeEntryId, LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 1 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 2 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 6 + new() { Id = unknownMorphTypeEntryId, LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 0 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 10 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 70 ]; var ids = expected.Select(e => e.Id).ToHashSet(); diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs index d021181e24..6f2ea07980 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/Sorting.cs @@ -33,12 +33,16 @@ public static IEnumerable ApplyHeadwordOrder(this IEnumerable public static IEnumerable ApplyRoughBestMatchOrder(this IEnumerable entries, SortOptions order, int sortWsHandle, int stemSecondaryOrder, string? query = null) { - var projected = entries.Select(e => (Entry: e, Headword: e.LexEntryHeadword(sortWsHandle, applyMorphTokens: false))); + var projected = entries.Select(e => ( + Entry: e, + Headword: e.LexEntryHeadword(sortWsHandle, applyMorphTokens: false), + HeadwordWithTokens: e.LexEntryHeadword(sortWsHandle, applyMorphTokens: true) + )); if (order.Ascending) { return projected - .OrderByDescending(x => !string.IsNullOrEmpty(query) && (x.Headword?.StartsWithDiacriticMatch(query!) ?? false)) - .ThenByDescending(x => !string.IsNullOrEmpty(query) && (x.Headword?.ContainsDiacriticMatch(query!) ?? false)) + .OrderByDescending(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.StartsWithDiacriticMatch(query!) ?? false)) + .ThenByDescending(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.ContainsDiacriticMatch(query!) ?? false)) .ThenBy(x => x.Headword?.Length ?? 0) .ThenBy(x => x.Headword) .ThenBy(x => x.Entry.PrimaryMorphType?.SecondaryOrder ?? stemSecondaryOrder) @@ -49,8 +53,8 @@ public static IEnumerable ApplyRoughBestMatchOrder(this IEnumerable !string.IsNullOrEmpty(query) && (x.Headword?.StartsWithDiacriticMatch(query!) ?? false)) - .ThenBy(x => !string.IsNullOrEmpty(query) && (x.Headword?.ContainsDiacriticMatch(query!) ?? false)) + .OrderBy(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.StartsWithDiacriticMatch(query!) ?? false)) + .ThenBy(x => !string.IsNullOrEmpty(query) && (x.HeadwordWithTokens?.ContainsDiacriticMatch(query!) ?? false)) .ThenByDescending(x => x.Headword?.Length ?? 0) .ThenByDescending(x => x.Headword) .ThenByDescending(x => x.Entry.PrimaryMorphType?.SecondaryOrder ?? stemSecondaryOrder) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs index fa6983d27d..eb883aaa68 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/FluentAssertGlobalConfig.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using FluentAssertions.Extensibility; using FwLiteProjectSync.Tests; @@ -7,6 +8,12 @@ namespace FwLiteProjectSync.Tests; public static class FluentAssertGlobalConfig { + [ModuleInitializer] + internal static void InitVerify() + { + VerifierSettings.OmitContentFromException(); + } + public static void Initialize() { MiniLcm.Tests.FluentAssertGlobalConfig.Initialize(); diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs index 954084d1bc..1c1f655e44 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs @@ -38,18 +38,7 @@ public async Task ImportProject_IsResumable_AcrossRandomFailures() }).ToList(); var expectedPartsOfSpeech = Enumerable.Range(1, 10) .Select(i => new PartOfSpeech { Id = Guid.NewGuid(), Name = { ["en"] = $"pos{i}" } }).ToList(); - var expectedMorphTypes = Enum.GetValues() - .Select(typ => new MorphType() - { - Id = Guid.NewGuid(), - Name = new() { ["en"] = $"Test Morph Type {(int)typ} {typ}" }, - Abbreviation = new() { ["en"] = $"Tst MrphTyp{(int)typ}" }, - Description = new() { { "en", new RichString($"test desc for {typ}") } }, - Prefix = null, - Postfix = null, - Kind = typ, - SecondaryOrder = 0 - }).ToList(); + var expectedMorphTypes = CanonicalMorphTypes.All.Values; var mockFrom = new Mock(); IMiniLcmApi mockTo = new UnreliableApi( @@ -132,7 +121,6 @@ public async Task ImportProject_IsResumable_AcrossRandomFailures() createdEntries.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(expectedEntries.Select(e => e.LexemeForm["en"])); createdMorphTypes.Select(e => e.Name["en"]).Should().BeEquivalentTo(expectedMorphTypes.Select(e => e.Name["en"])); createdMorphTypes.Select(e => e.Kind).Should().BeEquivalentTo(expectedMorphTypes.Select(e => e.Kind)); - } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index ed729a7045..5bf4fd967a 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -76,6 +76,18 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc { // Repair any missing translation IDs before doing the full sync, so the sync doesn't have to deal with them var syncedIdCount = await CrdtRepairs.SyncMissingTranslationIds(projectSnapshot.Entries, fwdata, crdt, dryRun); + + // Patch legacy snapshots that were created before morph-type support. + // After seeding, the CRDT has morph-types but the snapshot still has []. + // Without this patch, the diff would see all morph-types as "new" and try to re-add them. + if (projectSnapshot.MorphTypes.Length == 0) + { + var currentCrdtMorphTypes = await crdt.GetMorphTypes().ToArrayAsync(); + if (currentCrdtMorphTypes.Length > 0) + { + projectSnapshot = projectSnapshot with { MorphTypes = currentCrdtMorphTypes }; + } + } } var syncResult = projectSnapshot is null diff --git a/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs b/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs index 574dd10b12..f1a97956b8 100644 --- a/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs +++ b/backend/FwLite/FwLiteProjectSync/MiniLcmImport.cs @@ -8,6 +8,7 @@ using MiniLcm; using MiniLcm.Models; using MiniLcm.Project; +using MiniLcm.SyncHelpers; namespace FwLiteProjectSync; @@ -70,6 +71,11 @@ public async Task ImportProject(IMiniLcmApi importTo, IMiniLcmApi importFrom, in logger.LogInformation("Imported complex form type {Id}", complexFormType.Id); } + // Morph types are created automatically for CRDT projects, so we update them instead of creating them + var importFromMorphTypes = await importFrom.GetMorphTypes().ToArrayAsync(); + var existingMorphTypes = await importTo.GetMorphTypes().ToArrayAsync(); + await MorphTypeSync.Sync(existingMorphTypes, importFromMorphTypes, importTo); + await foreach (var morphType in importFrom.GetMorphTypes()) { await importTo.CreateMorphType(morphType); diff --git a/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs index 3c70a5184e..505a20b999 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/FilteringTests.cs @@ -12,9 +12,9 @@ public FilteringTests() _entries = [ new Entry { LexemeForm = { { "en", "123" } }, }, - new Entry { LexemeForm = { { "en", "456" } }, } + new Entry { LexemeForm = { { "en", "456" } }, }, ]; - _morphTypes = new MorphType[] { new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 } }.AsQueryable(); + _morphTypes = CanonicalMorphTypes.All.Values.ToArray().AsQueryable(); } [Theory] diff --git a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs index bbe2e496e1..b8c985e6c4 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs @@ -22,6 +22,7 @@ public class MigrationTests : IAsyncLifetime internal static void Init() { VerifySystemJson.Initialize(); + VerifierSettings.OmitContentFromException(); } public Task InitializeAsync() diff --git a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs index 412b433c9d..7be8eecf37 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/RegressionTestHelper.cs @@ -73,6 +73,6 @@ private static string GetFilePath(string name, [CallerFilePath] string sourceFil public enum RegressionVersion { v1, - v2 + v2, } } diff --git a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt index d1b59f5016..fccae78d32 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt @@ -1613,7 +1613,7 @@ "Analysis": null }, "Id": "72bcb2dd-4d73-486c-d5df-e5d7b9a6a3eb", - "DeletedAt": null, + "DeletedAt": null }, { "$type": "MiniLcmCrdtAdapter", diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index 6a4e0fd2c9..da6ba4d3e5 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -192,6 +192,14 @@ DerivedType: CreateCustomViewChange, TypeDiscriminator: CreateCustomViewChange }, + { + DerivedType: EditCustomViewChange, + TypeDiscriminator: EditCustomViewChange + }, + { + DerivedType: DeleteChange, + TypeDiscriminator: delete:CustomView + }, { DerivedType: CreateMorphTypeChange, TypeDiscriminator: CreateMorphTypeChange diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 0adf9acb29..7f68adc8db 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using LcmCrdt.MediaServer; +using LcmCrdt.Objects; using Meziantou.Extensions.Logging.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -71,9 +72,12 @@ public async Task InitializeAsync(string projectName) _crdtDbContext = await _services.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); await _crdtDbContext.Database.OpenConnectionAsync(); //can't use ProjectsService.CreateProject because it opens and closes the db context, this would wipe out the in memory db. + var projectData = new ProjectData("Sena 3", projectName, Guid.NewGuid(), null, Guid.NewGuid()); await CrdtProjectsService.InitProjectDb(_crdtDbContext, - new ProjectData("Sena 3", projectName, Guid.NewGuid(), null, Guid.NewGuid())); + projectData); await currentProjectService.RefreshProjectData(); + // also need to manually add morph-types, because we're not using CreateProject + await PreDefinedData.PredefinedMorphTypes(_services.ServiceProvider.GetRequiredService(), projectData.ClientId); if (_seedWs) { await Api.CreateWritingSystem(new WritingSystem() diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs index e32b8f355e..2f31d8f559 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/SortingTests.cs @@ -24,19 +24,10 @@ public override async Task DisposeAsync() [InlineData("a", SortField.SearchRelevance)] // non-FTS public async Task SecondaryOrder_DefaultsToStem(string query, SortField sortField) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Suffix, Name = { ["en"] = "Suffix" }, Postfix = "-", SecondaryOrder = 6 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - Entry[] expected = [ - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 1 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 2 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 6 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Unknown }, // SecondaryOrder defaults to Stem = 0 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.BoundStem }, // SecondaryOrder = 10 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 70 ]; var ids = expected.Select(e => e.Id).ToHashSet(); diff --git a/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs new file mode 100644 index 0000000000..74f7cdd4d4 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MorphTypeSeedingTests.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LcmCrdt.Tests; + +public class MorphTypeSeedingTests +{ + [Fact] + public async Task NewProjectWithSeedData_HasAllCanonicalMorphTypes() + { + var code = "morph-type-seed-test"; + var sqliteFile = $"{code}.sqlite"; + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + await using var scope = host.Services.CreateAsyncScope(); + + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + var crdtProject = await crdtProjectsService.CreateProject(new( + Name: "MorphTypeSeedTest", + Code: code, + Path: "", + SeedNewProjectData: true)); + + var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + + morphTypes.Should().BeEquivalentTo(CanonicalMorphTypes.All.Values); + + await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } + + [Fact] + public async Task ExistingProjectWithoutMorphTypes_GetsMorphTypesOnOpen() + { + var code = "morph-type-seed-existing"; + var sqliteFile = $"{code}.sqlite"; + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + await using var scope = host.Services.CreateAsyncScope(); + + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + // Create project WITHOUT seeding + var crdtProject = await crdtProjectsService.CreateProject(new( + Name: "MorphTypeSeedExisting", + Code: code, + Path: "", + SeedNewProjectData: false)); + + // Opening the project triggers MigrateDb, which seeds morph types if missing + var api = (CrdtMiniLcmApi)await scope.ServiceProvider.OpenCrdtProject(crdtProject); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); + + await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } + + [Fact] + public async Task SeedingIsIdempotent_OpeningProjectTwiceDoesNotDuplicate() + { + var code = "morph-type-seed-idempotent"; + var sqliteFile = $"{code}.sqlite"; + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + + // First open: seed morph types + { + await using var scope = host.Services.CreateAsyncScope(); + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + var crdtProject = await crdtProjectsService.CreateProject(new( + Name: "MorphTypeSeedIdempotent", + Code: code, + Path: "", + SeedNewProjectData: true)); + var api = await crdtProjectsService.OpenProject(crdtProject, scope.ServiceProvider); + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count, + "morph types should have been seeded"); + } + + // Second open: morph types + { + await using var scope = host.Services.CreateAsyncScope(); + var crdtProjectsService = scope.ServiceProvider.GetRequiredService(); + var crdtProject = crdtProjectsService.GetProject(code); + crdtProject.Should().NotBeNull(); + var api = await crdtProjectsService.OpenProject(crdtProject, scope.ServiceProvider); + // OpenProject calls MigrateDb(), which includes seeding morph types but only if they're not already seeded + var morphTypes = await api.GetMorphTypes().ToArrayAsync(); + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count, + "morph types should not be duplicated"); + + await using var dbContext = await scope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } + } + + [Fact] + public void CanonicalMorphTypes_CoverAllKindsExceptUnknown() + { + var allKinds = Enum.GetValues() + .Where(k => k != MorphTypeKind.Unknown) + .ToHashSet(); + + CanonicalMorphTypes.All.Keys.Should().BeEquivalentTo(allKinds); + } + + [Fact] + public void CanonicalMorphTypes_HaveRequiredFields() + { + foreach (var mt in CanonicalMorphTypes.All.Values) + { + mt.Id.Should().NotBe(Guid.Empty, $"MorphType {mt.Kind} should have a non-empty Id"); + mt.Name["en"].Should().NotBeNullOrWhiteSpace($"MorphType {mt.Kind} should have an English name"); + mt.Abbreviation["en"].Should().NotBeNullOrWhiteSpace($"MorphType {mt.Kind} should have an English abbreviation"); + mt.Description["en"].IsEmpty.Should().BeFalse($"MorphType {mt.Kind} should have an English description"); + } + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index 269ae2a0b2..997012e4c6 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -51,7 +51,7 @@ public async Task ProjectDbIsDeletedIfCreateFails() [Fact] public async Task OpeningAProjectWorks() { - var sqliteConnectionString = "OpeningAProjectWorks.sqlite"; + var sqliteConnectionString = "opening-a-project-works.sqlite"; if (File.Exists(sqliteConnectionString)) File.Delete(sqliteConnectionString); var builder = Host.CreateEmptyApplicationBuilder(null); builder.Services.AddTestLcmCrdtClient(); diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 457fe2f12f..fcce05c721 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -245,6 +245,7 @@ internal static async Task SeedSystemData(DataModel dataModel, Guid clientId) await PreDefinedData.PredefinedPartsOfSpeech(dataModel, clientId); await PreDefinedData.PredefinedSemanticDomains(dataModel, clientId); await PreDefinedData.PredefinedCustomViews(dataModel, clientId); + await PreDefinedData.PredefinedMorphTypes(dataModel, clientId); } [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index a31a6f7ed2..7904721cdb 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; using LcmCrdt.FullTextSearch; +using LcmCrdt.Objects; using LcmCrdt.Project; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SIL.Harmony; namespace LcmCrdt; @@ -105,9 +107,19 @@ async Task Execute() { await using var dbContext = await DbContextFactory.CreateDbContextAsync(); await dbContext.Database.MigrateAsync(); + + // Seed morph-types if missing (for existing projects created before morph-type support). + // Must happen BEFORE FTS regeneration so headwords include morph-type tokens. + if (!await dbContext.MorphTypes.AnyAsync()) + { + var dataModel = services.GetRequiredService(); + var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); + await PreDefinedData.PredefinedMorphTypes(dataModel, projectData.ClientId); + } + if (EntrySearchServiceFactory is not null) { - await using var ess = EntrySearchServiceFactory.CreateSearchService(dbContext); + await using var ess = EntrySearchServiceFactory.CreateSearchService(dbContext); await ess.RegenerateIfMissing(); } } diff --git a/backend/FwLite/LcmCrdt/Data/Sorting.cs b/backend/FwLite/LcmCrdt/Data/Sorting.cs index acb0690737..6a26954438 100644 --- a/backend/FwLite/LcmCrdt/Data/Sorting.cs +++ b/backend/FwLite/LcmCrdt/Data/Sorting.cs @@ -48,8 +48,8 @@ from e in entries join mt in morphTypes on e.MorphType equals mt.Kind into mtGroup from mt in mtGroup.DefaultIfEmpty() orderby - !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.Headword(order.WritingSystem), query!) descending, - !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.Headword(order.WritingSystem), query!) descending, + !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!) descending, + !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!) descending, e.Headword(order.WritingSystem).Length, e.Headword(order.WritingSystem), mt != null ? mt.SecondaryOrder : stemOrder.FirstOrDefault(), @@ -64,8 +64,8 @@ from e in entries join mt in morphTypes on e.MorphType equals mt.Kind into mtGroup from mt in mtGroup.DefaultIfEmpty() orderby - !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.Headword(order.WritingSystem), query!), - !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.Headword(order.WritingSystem), query!), + !string.IsNullOrEmpty(query) && SqlHelpers.StartsWithIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!), + !string.IsNullOrEmpty(query) && SqlHelpers.ContainsIgnoreCaseAccents(e.HeadwordWithTokens(order.WritingSystem, mt.Prefix, mt.Postfix), query!), e.Headword(order.WritingSystem).Length descending, e.Headword(order.WritingSystem) descending, (mt != null ? mt.SecondaryOrder : stemOrder.FirstOrDefault()) descending, diff --git a/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs new file mode 100644 index 0000000000..d67a97f6e0 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.Designer.cs @@ -0,0 +1,785 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260318120000_RegenerateSearchTableForMorphTypes")] + partial class RegenerateSearchTableForMorphTypes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Postfix") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SecondaryOrder") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Kind") + .IsUnique(); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("MorphType"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.MorphType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs new file mode 100644 index 0000000000..a6f9e1d491 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260318120000_RegenerateSearchTableForMorphTypes.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class RegenerateSearchTableForMorphTypes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Force FTS rebuild so headwords include morph-type prefix/postfix tokens + migrationBuilder.Sql("DELETE FROM EntrySearchRecord;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // FTS table will be lazily regenerated + } + } +} diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index 37ab3d0db5..682d77b75a 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -73,4 +73,11 @@ await dataModel.AddChanges(clientId, ], new Guid("b2c3d4e5-f6a7-8901-bcde-f12345678901")); } + + internal static async Task PredefinedMorphTypes(DataModel dataModel, Guid clientId) + { + await dataModel.AddChanges(clientId, + [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))], + new Guid("a7b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d")); + } } diff --git a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs index 2481fae96b..c8f9269540 100644 --- a/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs @@ -523,14 +523,6 @@ public async Task PunctuationWorks(string searchTerm, string word) public async Task SearchEntries_MatchesLexeme(string searchTerm) { var prefixQuery = $"{searchTerm}-"; - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); var lexemeOnlyMatchEntry = await Api.CreateEntry(new Entry { LexemeForm = { ["en"] = "mango" }, @@ -554,14 +546,6 @@ await Api.CreateMorphType(new MorphType public async Task SearchEntries_CitationFormOverridesMorphTokens(string searchTerm) { var prefixQuery = $"{searchTerm}-"; - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); var entryWithOverriddenMorphToken = await Api.CreateEntry(new Entry { LexemeForm = { ["en"] = "mango" }, @@ -578,14 +562,6 @@ await Api.CreateMorphType(new MorphType [InlineData("o-")] // non-FTS public async Task MorphTokenSearch_FindsPrefixEntry(string searchTerm) { - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); var id = Guid.NewGuid(); await Api.CreateEntry(new Entry { Id = id, LexemeForm = { ["en"] = "mango" }, MorphType = MorphTypeKind.Prefix }); @@ -598,14 +574,6 @@ await Api.CreateMorphType(new MorphType [InlineData("-m")] // non-FTS public async Task MorphTokenSearch_FindsSuffixEntry(string searchTerm) { - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Suffix, - Name = { ["en"] = "Suffix" }, - Prefix = "-", - SecondaryOrder = 6 - }); var id = Guid.NewGuid(); await Api.CreateEntry(new Entry { Id = id, LexemeForm = { ["en"] = "mango" }, MorphType = MorphTypeKind.Suffix }); @@ -616,14 +584,6 @@ await Api.CreateMorphType(new MorphType [Fact] public async Task MorphTokenSearch_DoesNotMatchWithoutToken() { - await Api.CreateMorphType(new MorphType - { - Id = Guid.NewGuid(), - Kind = MorphTypeKind.Prefix, - Name = { ["en"] = "Prefix" }, - Postfix = "-", - SecondaryOrder = 3 - }); await Api.CreateEntry(new Entry { LexemeForm = { ["en"] = "mango" }, MorphType = MorphTypeKind.Root }); // Searching for "-mango" should NOT match a Root entry (no morph tokens) diff --git a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs index a815324133..48ab0f8f97 100644 --- a/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/SortingTestsBase.cs @@ -55,6 +55,35 @@ await Api.GetEntries(new QueryOptions(new SortOptions(SortField.Headword, wsId)) .ToArrayAsync(); } + [Theory] + [InlineData("a-", SortField.Headword)] // non-FTS + [InlineData("a-", SortField.SearchRelevance)] // non-FTS + [InlineData("aaaa-", SortField.Headword)] // FTS + [InlineData("aaaa-", SortField.SearchRelevance)] // FTS + public async Task MorphTokenSearch_PrefixHeadwordBeatsIncidentalContains(string query, SortField sortField) + { + // An entry with lexeme "a" and MorphType=Prefix has headword "a-". + // An entry "toma-toma" incidentally contains "a-" in the middle. + // The prefix entry should sort first because it's a headword-starts-with match, + // not just an incidental contains match. + var baseForm = query.TrimEnd('-'); + Entry prefixEntry = new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = baseForm }, MorphType = MorphTypeKind.Prefix }; + Entry containsEntry = new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = $"tom{baseForm}-tom" }, MorphType = MorphTypeKind.Root }; + + var ids = new[] { prefixEntry.Id, containsEntry.Id }.ToHashSet(); + + // Insert in reverse order to ensure sorting is actually tested + await Api.CreateEntry(containsEntry); + await Api.CreateEntry(prefixEntry); + + var results = (await Api.SearchEntries(query, new(new(sortField))).ToArrayAsync()) + .Where(e => ids.Contains(e.Id)) + .ToList(); + + results.Should().BeEquivalentTo([prefixEntry, containsEntry], + options => options.WithStrictOrdering()); + } + [Theory] [InlineData("aaaa", SortField.Headword)] // FTS [InlineData("a", SortField.Headword)] // non-FTS @@ -62,24 +91,15 @@ await Api.GetEntries(new QueryOptions(new SortOptions(SortField.Headword, wsId)) [InlineData("a", SortField.SearchRelevance)] // non-FTS public async Task MorphTokens_DoNotAffectSortOrder(string query, SortField sortField) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Prefix, Name = { ["en"] = "Prefix" }, Prefix = "-", SecondaryOrder = 3 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Suffix, Name = { ["en"] = "Suffix" }, Postfix = "-", SecondaryOrder = 6 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - // All three entries have LexemeForm "aaaa". Their headwords are: // Root: "aaaa" (no tokens) // Prefix: "-aaaa" (leading token "-") // Suffix: "aaaa-" (trailing token "-") // Sort order should ignore morph tokens and differentiate only by SecondaryOrder. Entry[] expected = [ - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Root }, // SecondaryOrder = 1 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Prefix }, // SecondaryOrder = 3 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 6 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Root }, // SecondaryOrder = 0 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Prefix }, // SecondaryOrder = 20 + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "aaaa" }, MorphType = MorphTypeKind.Suffix }, // SecondaryOrder = 70 ]; var ids = expected.Select(e => e.Id).ToHashSet(); @@ -100,25 +120,15 @@ public async Task MorphTokens_DoNotAffectSortOrder(string query, SortField sortF [InlineData("a")] // non-FTS rank public async Task SecondaryOrder_Relevance_LexemeForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - static Entry[] CreateSortedEntrySet(string headword) { return [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 - new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, - // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, + // Prefix - SecondaryOrder: 20 (no leading token, so headword still starts with the lexeme) + new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = headword }, MorphType = MorphTypeKind.Prefix/*, HomographNumber = 1*/ }, + // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = lexeme }, MorphType = MorphTypeKind.Prefix, HomographNumber = 2 }, ]; } @@ -168,23 +178,13 @@ static Entry[] CreateSortedEntrySet(string headword) [InlineData("a")] // non-FTS rank public async Task SecondaryOrder_Relevance_CitationForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - static Entry[] CreateSortedEntrySet(string headword) { return [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = headword }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; @@ -236,27 +236,17 @@ static Entry[] CreateSortedEntrySet(string headword) [InlineData("b")] // non-FTS rank public async Task SecondaryOrder_Headword_LexemeForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - Entry[] expected = [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "abaaa" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), LexemeForm = { ["en"] = "baaa" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; @@ -281,27 +271,17 @@ public async Task SecondaryOrder_Headword_LexemeForm(string searchTerm) [InlineData("b")] // non-FTS rank public async Task SecondaryOrder_Headword_CitationForm(string searchTerm) { - MorphType[] morphTypes = [ - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Root, Name = { ["en"] = "Root" }, SecondaryOrder = 1 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.Stem, Name = { ["en"] = "Stem" }, SecondaryOrder = 1 }, - new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundRoot, Name = { ["en"] = "BoundRoot" }, SecondaryOrder = 2 }, - // new() { Id = Guid.NewGuid(), Kind = MorphTypeKind.BoundStem, Name = { ["en"] = "BoundStem" }, SecondaryOrder = 2 }, - ]; - - foreach (var morphType in morphTypes) - await Api.CreateMorphType(morphType); - Entry[] expected = [ - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "abaaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, - // Root/Stem - SecondaryOrder: 1 + // Root/Stem - SecondaryOrder: 0 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Root/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.Stem, HomographNumber = 2 }, - // BoundRoot/BoundStem - SecondaryOrder: 2 + // BoundRoot/BoundStem - SecondaryOrder: 10 new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundRoot/*, HomographNumber = 1*/ }, // new() { Id = Guid.NewGuid(), CitationForm = { ["en"] = "baaa" }, LexemeForm = { ["en"] = "❌" }, MorphType = MorphTypeKind.BoundStem, HomographNumber = 2 }, ]; diff --git a/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs new file mode 100644 index 0000000000..fd84b961f2 --- /dev/null +++ b/backend/FwLite/MiniLcm/Models/CanonicalMorphTypes.cs @@ -0,0 +1,218 @@ +using System.Collections.Frozen; + +namespace MiniLcm.Models; + +/// +/// Canonical morph-type definitions copied from: +/// https://github.com/sillsdev/liblcm/blob/master/src/SIL.LCModel/Templates/NewLangProj.fwdata +/// +public static class CanonicalMorphTypes +{ + public static readonly FrozenDictionary All = CreateAll().ToFrozenDictionary(m => m.Kind); + + private static MorphType[] CreateAll() + { + return + [ + new() + { + Id = new Guid("d7f713e4-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.BoundRoot, + Name = new MultiString { { "en", "bound root" } }, + Abbreviation = new MultiString { { "en", "bd root" } }, + Description = new RichMultiString { { "en", new RichString("A bound root is a root which cannot occur as a separate word apart from any other morpheme.") } }, + Prefix = "*", + SecondaryOrder = 10, + }, + new() + { + Id = new Guid("d7f713e7-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.BoundStem, + Name = new MultiString { { "en", "bound stem" } }, + Abbreviation = new MultiString { { "en", "bd stem" } }, + Description = new RichMultiString { { "en", new RichString("A bound stem is a stem which cannot occur as a separate word apart from any other morpheme.") } }, + Prefix = "*", + SecondaryOrder = 10, + }, + new() + { + Id = new Guid("d7f713df-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Circumfix, + Name = new MultiString { { "en", "circumfix" } }, + Abbreviation = new MultiString { { "en", "cfx" } }, + Description = new RichMultiString { { "en", new RichString("A circumfix is an affix made up of two separate parts which surround and attach to a root or stem.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("c2d140e5-7ca9-41f4-a69a-22fc7049dd2c"), + Kind = MorphTypeKind.Clitic, + Name = new MultiString { { "en", "clitic" } }, + Abbreviation = new MultiString { { "en", "clit" } }, + Description = new RichMultiString { { "en", new RichString("A clitic is a morpheme that has syntactic characteristics of a word, but shows evidence of being phonologically bound to another word. Orthographically, it stands alone.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713e1-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Enclitic, + Name = new MultiString { { "en", "enclitic" } }, + Abbreviation = new MultiString { { "en", "enclit" } }, + Description = new RichMultiString { { "en", new RichString("An enclitic is a clitic that is phonologically joined at the end of a preceding word to form a single unit. Orthographically, it may attach to the preceding word.") } }, + Prefix = "=", + SecondaryOrder = 80, + }, + new() + { + Id = new Guid("d7f713da-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Infix, + Name = new MultiString { { "en", "infix" } }, + Abbreviation = new MultiString { { "en", "ifx" } }, + Description = new RichMultiString { { "en", new RichString("An infix is an affix that is inserted within a root or stem.") } }, + Prefix = "-", + Postfix = "-", + SecondaryOrder = 40, + }, + new() + { + Id = new Guid("56db04bf-3d58-44cc-b292-4c8aa68538f4"), + Kind = MorphTypeKind.Particle, + Name = new MultiString { { "en", "particle" } }, + Abbreviation = new MultiString { { "en", "part" } }, + Description = new RichMultiString { { "en", new RichString("A particle is a word that does not belong to one of the main classes of words, is invariable in form, and typically has grammatical or pragmatic meaning.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713db-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Prefix, + Name = new MultiString { { "en", "prefix" } }, + Abbreviation = new MultiString { { "en", "pfx" } }, + Description = new RichMultiString { { "en", new RichString("A prefix is an affix that is joined before a root or stem.") } }, + Postfix = "-", + SecondaryOrder = 20, + }, + new() + { + Id = new Guid("d7f713e2-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Proclitic, + Name = new MultiString { { "en", "proclitic" } }, + Abbreviation = new MultiString { { "en", "proclit" } }, + Description = new RichMultiString { { "en", new RichString("A proclitic is a clitic that precedes the word to which it is phonologically joined. Orthographically, it may attach to the following word.") } }, + Postfix = "=", + SecondaryOrder = 30, + }, + new() + { + Id = new Guid("d7f713e5-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Root, + Name = new MultiString { { "en", "root" } }, + Abbreviation = new MultiString { { "en", "ubd root" } }, + Description = new RichMultiString { { "en", new RichString("A root is the portion of a word that (i) is common to a set of derived or inflected forms, if any, when all affixes are removed, (ii) is not further analyzable into meaningful elements, being morphologically simple, and, (iii) carries the principal portion of meaning of the words in which it functions.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713dc-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Simulfix, + Name = new MultiString { { "en", "simulfix" } }, + Abbreviation = new MultiString { { "en", "smfx" } }, + Description = new RichMultiString { { "en", new RichString("A simulfix is a change or replacement of vowels or consonants (usually vowels) which changes the meaning of a word. (Note: the parser does not currently handle simulfixes.)") } }, + Prefix = "=", + Postfix = "=", + SecondaryOrder = 60, + }, + new() + { + Id = new Guid("d7f713e8-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Stem, + Name = new MultiString { { "en", "stem" } }, + Abbreviation = new MultiString { { "en", "ubd stem" } }, + Description = new RichMultiString { { "en", new RichString([ + new RichSpan { Text = "\"A stem is the root or roots of a word, together with any derivational affixes, to which inflectional affixes are added.\" (LinguaLinks Library). A stem \"may consist solely of a single root morpheme (i.e. a 'simple' stem as in " }, + new RichSpan { Text = "man", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = "), or of two root morphemes (e.g. a 'compound' stem, as in " }, + new RichSpan { Text = "blackbird", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = "), or of a root morpheme plus a derivational affix (i.e. a 'complex' stem, as in " }, + new RichSpan { Text = "manly", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = ", " }, + new RichSpan { Text = "unmanly", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = ", " }, + new RichSpan { Text = "manliness", NamedStyle = "Emphasized Text" }, + new RichSpan { Text = "). All have in common the notion that it is to the stem that inflectional affixes are attached.\" (Crystal, 1997:362)" }, + ]) } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("d7f713dd-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Suffix, + Name = new MultiString { { "en", "suffix" } }, + Abbreviation = new MultiString { { "en", "sfx" } }, + Description = new RichMultiString { { "en", new RichString("A suffix is an affix that is attached to the end of a root or stem.") } }, + Prefix = "-", + SecondaryOrder = 70, + }, + new() + { + Id = new Guid("d7f713de-e8cf-11d3-9764-00c04f186933"), + Kind = MorphTypeKind.Suprafix, + Name = new MultiString { { "en", "suprafix" } }, + Abbreviation = new MultiString { { "en", "spfx" } }, + Description = new RichMultiString { { "en", new RichString("A suprafix is a kind of affix in which a suprasegmental is superimposed on one or more syllables of the root or stem, signalling a particular morphosyntactic operation. (Note: the parser does not currently handle suprafixes.)") } }, + Prefix = "~", + Postfix = "~", + SecondaryOrder = 50, + }, + new() + { + Id = new Guid("18d9b1c3-b5b6-4c07-b92c-2fe1d2281bd4"), + Kind = MorphTypeKind.InfixingInterfix, + Name = new MultiString { { "en", "infixing interfix" } }, + Abbreviation = new MultiString { { "en", "ifxnfx" } }, + Description = new RichMultiString { { "en", new RichString("An infixing interfix is an infix that can occur between two roots or stems.") } }, + Prefix = "-", + Postfix = "-", + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("af6537b0-7175-4387-ba6a-36547d37fb13"), + Kind = MorphTypeKind.PrefixingInterfix, + Name = new MultiString { { "en", "prefixing interfix" } }, + Abbreviation = new MultiString { { "en", "pfxnfx" } }, + Description = new RichMultiString { { "en", new RichString("A prefixing interfix is a prefix that can occur between two roots or stems.") } }, + Postfix = "-", + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("3433683d-08a9-4bae-ae53-2a7798f64068"), + Kind = MorphTypeKind.SuffixingInterfix, + Name = new MultiString { { "en", "suffixing interfix" } }, + Abbreviation = new MultiString { { "en", "sfxnfx" } }, + Description = new RichMultiString { { "en", new RichString("A suffixing interfix is a suffix that can occur between two roots or stems.") } }, + Prefix = "-", + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("a23b6faa-1052-4f4d-984b-4b338bdaf95f"), + Kind = MorphTypeKind.Phrase, + Name = new MultiString { { "en", "phrase" } }, + Abbreviation = new MultiString { { "en", "phr" } }, + Description = new RichMultiString { { "en", new RichString("A phrase is a syntactic structure that consists of more than one word but lacks the subject-predicate organization of a clause.") } }, + SecondaryOrder = 0, + }, + new() + { + Id = new Guid("0cc8c35a-cee9-434d-be58-5d29130fba5b"), + Kind = MorphTypeKind.DiscontiguousPhrase, + Name = new MultiString { { "en", "discontiguous phrase" } }, + Abbreviation = new MultiString { { "en", "dis phr" } }, + Description = new RichMultiString { { "en", new RichString("A discontiguous phrase has discontiguous constituents which (a) are separated from each other by one or more intervening constituents, and (b) are considered either (i) syntactically contiguous and unitary, or (ii) realizing the same, single meaning. An example is French ne...pas.") } }, + SecondaryOrder = 0, + }, + ]; + } +}