From 5d89a7310ad1a41c3353b07cbba24ad34a591ddc Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 7 Oct 2026 11:40:10 +0700 Subject: [PATCH] Split sense API out of CrdtMiniLcmApi Move the sense methods into CrdtSenseApi. Following how entry links went to their reference-type services, AddSemanticDomainToSense and RemoveSemanticDomainFromSense move to CrdtSemanticDomainsApi and SetSensePartOfSpeech to CrdtPartsOfSpeechApi. CreateSenseChanges and VerifySenseBelongsToEntry become internal statics on CrdtSenseApi, since CreateEntry and the example sentence methods still use them from the facade. Co-Authored-By: Claude Opus 5.5 --- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 120 ++++------------ backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 1 + .../MiniLcmImp/CrdtPartsOfSpeechApi.cs | 5 + .../MiniLcmImp/CrdtSemanticDomainsApi.cs | 10 ++ .../FwLite/LcmCrdt/MiniLcmImp/CrdtSenseApi.cs | 133 ++++++++++++++++++ 5 files changed, 180 insertions(+), 89 deletions(-) create mode 100644 backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSenseApi.cs diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 83dd453de3..4af3850f50 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -29,6 +29,7 @@ public class CrdtMiniLcmApi( CrdtMorphTypeApi morphTypeApi, CrdtPartsOfSpeechApi partsOfSpeechApi, CrdtComplexFormTypesApi complexFormTypesApi, + CrdtSenseApi senseApi, CrdtPictureApi pictureApi, CrdtMediaApi mediaApi, CrdtCustomViewApi customViewApi, @@ -113,6 +114,11 @@ public async Task DeletePartOfSpeech(Guid id) { await partsOfSpeechApi.DeletePartOfSpeech(id); } + + public async Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) + { + await partsOfSpeechApi.SetSensePartOfSpeech(senseId, partOfSpeechId); + } #endregion #region PublicationApi @@ -202,6 +208,16 @@ public async Task BulkImportSemanticDomains(IAsyncEnumerable sem { await semanticDomainsApi.BulkImportSemanticDomains(semanticDomains); } + + public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + await semanticDomainsApi.AddSemanticDomainToSense(senseId, semanticDomain); + } + + public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + await semanticDomainsApi.RemoveSemanticDomainFromSense(senseId, semanticDomainId); + } #endregion #region ComplexFormTypeApi @@ -460,7 +476,7 @@ ..await entry.Senses.ToAsyncEnumerable() .SelectMany((s, i) => { s.Order = i + 1; - return CreateSenseChanges(entry.Id, s, repo.SemanticDomains); + return CrdtSenseApi.CreateSenseChanges(entry.Id, s, repo.SemanticDomains); }) .ToArrayAsync(), ..await ToPublications(entry.PublishIn).ToArrayAsync(), @@ -583,133 +599,59 @@ public async Task DeleteEntry(Guid id) await harmonyChangeWriter.AddChange(new DeleteChange(id)); } - private async IAsyncEnumerable CreateSenseChanges(Guid entryId, - Sense sense, - IQueryable semanticDomains) - { - sense.SemanticDomains = await semanticDomains - .Where(sd => sense.SemanticDomains.Select(s => s.Id).Contains(sd.Id)) - .ToListAsync(); - - yield return new CreateSenseChange(sense, entryId); - var exampleOrder = 1; - foreach (var exampleSentence in sense.ExampleSentences) - { - exampleSentence.Order = exampleOrder++; - yield return new CreateExampleSentenceChange(exampleSentence, sense.Id); - } - } - + #region SenseApi public async Task GetSense(Guid senseId) { - await using var repo = await repoFactory.CreateRepoAsync(); - return await repo.GetSense(senseId); + return await senseApi.GetSense(senseId); } public async Task GetSense(Guid entryId, Guid senseId) { - await using var repo = await repoFactory.CreateRepoAsync(); - var sense = await repo.GetSense(senseId); - if (sense is null) return null; - VerifySenseBelongsToEntry(entryId, sense); - return sense; - } - - private static void VerifySenseBelongsToEntry(Guid entryId, Sense sense) - { - if (sense.EntryId != entryId) throw ParentMismatchException.ForType(sense.Id, entryId, sense.EntryId); + return await senseApi.GetSense(entryId, senseId); } public async Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? between = null) { - await using var repo = await repoFactory.CreateRepoAsync(); - sense.Order = await OrderPicker.PickOrder(repo.Senses.Where(s => s.EntryId == entryId), between); - await harmonyChangeWriter.AddChanges(await CreateSenseChanges(entryId, sense, repo.SemanticDomains).ToArrayAsync()); + await senseApi.SubmitCreateSense(entryId, sense, between); } public async Task CreateSense(Guid entryId, Sense sense, BetweenPosition? between = null) { - if (sense.PartOfSpeechId.HasValue && await GetPartOfSpeech(sense.PartOfSpeechId.Value) is null) - throw new InvalidOperationException($"Part of speech must exist when creating a sense (could not find GUID {sense.PartOfSpeechId.Value})"); - - await SubmitCreateSense(entryId, sense, between); - return await GetSense(entryId, sense.Id) ?? throw NotFoundException.ForType(sense.Id); + return await senseApi.CreateSense(entryId, sense, between); } public async Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { - await harmonyChangeWriter.AddChanges(update.Patch.ToChanges(senseId)); + await senseApi.SubmitUpdateSense(entryId, senseId, update); } public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { - await SubmitUpdateSense(entryId, senseId, update); - return await GetSense(entryId, senseId) ?? throw NotFoundException.ForType(senseId); + return await senseApi.UpdateSense(entryId, senseId, update); } public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api = null) { - await SenseSync.Sync(entryId, before, after, api ?? this, - SyncContext.For(before, after, deferDeletes: false)); - return await GetSense(entryId, after.Id) ?? throw NotFoundException.ForType(after.Id); + return await senseApi.UpdateSense(entryId, before, after, api ?? this); } public async Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between, MoveKind kind = MoveKind.Reorder) { - await using var repo = await repoFactory.CreateRepoAsync(); - var sense = await repo.GetSense(senseId) ?? throw NotFoundException.ForType(senseId); - if (kind == MoveKind.Reorder) - { - // SetOrder doesn't re-parent, so an order picked against another entry's senses would be silently wrong - VerifySenseBelongsToEntry(entryId, sense); - await harmonyChangeWriter.AddChange(new Changes.SetOrderChange(senseId, await PickSenseOrder(repo, entryId, between))); - return; - } - if (!await repo.Entries.AnyAsyncEF(e => e.Id == entryId)) throw NotFoundException.ForType(entryId); - await harmonyChangeWriter.AddChange(new MoveSenseToEntryChange(senseId, entryId, await PickSenseOrder(repo, entryId, between))); + await senseApi.MoveSense(entryId, senseId, between, kind); } public async Task SubmitMoveSense(Guid entryId, Guid senseId, BetweenPosition position, MoveKind kind = MoveKind.Reorder) { - await using var repo = await repoFactory.CreateRepoAsync(); - if (kind == MoveKind.Reorder) - { - // the sense is gone or was reparented on this side: the reorder is moot, skip it - var sense = await repo.GetSense(senseId); - if (sense is null || sense.EntryId != entryId) return; - await harmonyChangeWriter.AddChange(new Changes.SetOrderChange(senseId, await PickSenseOrder(repo, entryId, position))); - return; - } - // no target check: a deleted target entry is fine, the move change then deletes the sense (delete wins) - await harmonyChangeWriter.AddChange(new MoveSenseToEntryChange(senseId, entryId, await PickSenseOrder(repo, entryId, position))); - } - - private static async Task PickSenseOrder(MiniLcmRepository repo, Guid entryId, BetweenPosition between) - { - return await OrderPicker.PickOrder(repo.Senses.Where(s => s.EntryId == entryId), between); + await senseApi.SubmitMoveSense(entryId, senseId, position, kind); } public async Task DeleteSense(Guid entryId, Guid senseId) { - await harmonyChangeWriter.AddChange(new DeleteChange(senseId)); - } - - public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) - { - await harmonyChangeWriter.AddChange(new AddSemanticDomainChange(semanticDomain, senseId)); - } - - public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) - { - await harmonyChangeWriter.AddChange(new RemoveSemanticDomainChange(semanticDomainId, senseId)); - } - - public async Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) - { - await harmonyChangeWriter.AddChange(new SetPartOfSpeechChange(senseId, partOfSpeechId)); + await senseApi.DeleteSense(entryId, senseId); } + #endregion public async Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, @@ -742,7 +684,7 @@ public async Task CreateExampleSentence(Guid entryId, var sense = await repo.GetSense(senseId); if (sense is not null) { - VerifySenseBelongsToEntry(entryId, sense); + CrdtSenseApi.VerifySenseBelongsToEntry(entryId, sense); var owned = sense.ExampleSentences.FirstOrDefault(e => e.Id == id); if (owned is not null) return owned; } @@ -791,7 +733,7 @@ public async Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId } if (!await repo.ExampleSentences.AnyAsyncEF(e => e.Id == exampleId)) throw NotFoundException.ForType(exampleId); var targetSense = await repo.GetSense(senseId) ?? throw NotFoundException.ForType(senseId); - VerifySenseBelongsToEntry(entryId, targetSense); + CrdtSenseApi.VerifySenseBelongsToEntry(entryId, targetSense); await harmonyChangeWriter.AddChange(new MoveExampleSentenceToSenseChange(exampleId, senseId, await PickExampleOrder(repo, senseId, between))); } diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index ba83a1376a..ef2da21486 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -90,6 +90,7 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtPartsOfSpeechApi.cs b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtPartsOfSpeechApi.cs index d561b95fd6..2efee4f75b 100644 --- a/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtPartsOfSpeechApi.cs +++ b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtPartsOfSpeechApi.cs @@ -54,4 +54,9 @@ public async Task DeletePartOfSpeech(Guid id) { await harmonyChangeWriter.AddChange(new DeleteChange(id)); } + + public async Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) + { + await harmonyChangeWriter.AddChange(new SetPartOfSpeechChange(senseId, partOfSpeechId)); + } } diff --git a/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSemanticDomainsApi.cs b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSemanticDomainsApi.cs index fc688cf090..90f76b566f 100644 --- a/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSemanticDomainsApi.cs +++ b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSemanticDomainsApi.cs @@ -58,4 +58,14 @@ public async Task BulkImportSemanticDomains(IAsyncEnumerable sem { await harmonyChangeWriter.AddChanges(await semanticDomains.Select(sd => new CreateSemanticDomainChange(sd)).ToArrayAsync()); } + + public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + await harmonyChangeWriter.AddChange(new AddSemanticDomainChange(semanticDomain, senseId)); + } + + public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + await harmonyChangeWriter.AddChange(new RemoveSemanticDomainChange(semanticDomainId, senseId)); + } } diff --git a/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSenseApi.cs b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSenseApi.cs new file mode 100644 index 0000000000..3485ac3857 --- /dev/null +++ b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtSenseApi.cs @@ -0,0 +1,133 @@ +using LcmCrdt.Changes; +using LcmCrdt.Data; +using LcmCrdt.Harmony; +using LcmCrdt.Objects; +using LinqToDB.Async; +using LinqToDB.EntityFrameworkCore; +using MiniLcm.Exceptions; +using MiniLcm.SyncHelpers; +using SIL.Harmony.Changes; + +namespace LcmCrdt.MiniLcmImp; + +public class CrdtSenseApi(MiniLcmRepositoryFactory repoFactory, HarmonyChangeWriter harmonyChangeWriter) +{ + public async Task GetSense(Guid senseId) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetSense(senseId); + } + + public async Task GetSense(Guid entryId, Guid senseId) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var sense = await repo.GetSense(senseId); + if (sense is null) return null; + VerifySenseBelongsToEntry(entryId, sense); + return sense; + } + + internal static void VerifySenseBelongsToEntry(Guid entryId, Sense sense) + { + if (sense.EntryId != entryId) throw ParentMismatchException.ForType(sense.Id, entryId, sense.EntryId); + } + + public async Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? between = null) + { + await using var repo = await repoFactory.CreateRepoAsync(); + sense.Order = await OrderPicker.PickOrder(repo.Senses.Where(s => s.EntryId == entryId), between); + await harmonyChangeWriter.AddChanges(await CreateSenseChanges(entryId, sense, repo.SemanticDomains).ToArrayAsync()); + } + + public async Task CreateSense(Guid entryId, Sense sense, BetweenPosition? between = null) + { + if (sense.PartOfSpeechId.HasValue && !await PartOfSpeechExists(sense.PartOfSpeechId.Value)) + throw new InvalidOperationException($"Part of speech must exist when creating a sense (could not find GUID {sense.PartOfSpeechId.Value})"); + + await SubmitCreateSense(entryId, sense, between); + return await GetSense(entryId, sense.Id) ?? throw NotFoundException.ForType(sense.Id); + } + + private async Task PartOfSpeechExists(Guid partOfSpeechId) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.PartsOfSpeech.AnyAsync(pos => pos.Id == partOfSpeechId); + } + + internal static async IAsyncEnumerable CreateSenseChanges(Guid entryId, + Sense sense, + IQueryable semanticDomains) + { + sense.SemanticDomains = await semanticDomains + .Where(sd => sense.SemanticDomains.Select(s => s.Id).Contains(sd.Id)) + .ToListAsync(); + + yield return new CreateSenseChange(sense, entryId); + var exampleOrder = 1; + foreach (var exampleSentence in sense.ExampleSentences) + { + exampleSentence.Order = exampleOrder++; + yield return new CreateExampleSentenceChange(exampleSentence, sense.Id); + } + } + + public async Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + await harmonyChangeWriter.AddChanges(update.Patch.ToChanges(senseId)); + } + + public async Task UpdateSense(Guid entryId, + Guid senseId, + UpdateObjectInput update) + { + await SubmitUpdateSense(entryId, senseId, update); + return await GetSense(entryId, senseId) ?? throw NotFoundException.ForType(senseId); + } + + public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi api) + { + await SenseSync.Sync(entryId, before, after, api, + SyncContext.For(before, after, deferDeletes: false)); + return await GetSense(entryId, after.Id) ?? throw NotFoundException.ForType(after.Id); + } + + public async Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between, MoveKind kind = MoveKind.Reorder) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var sense = await repo.GetSense(senseId) ?? throw NotFoundException.ForType(senseId); + if (kind == MoveKind.Reorder) + { + // SetOrder doesn't re-parent, so an order picked against another entry's senses would be silently wrong + VerifySenseBelongsToEntry(entryId, sense); + await harmonyChangeWriter.AddChange(new Changes.SetOrderChange(senseId, await PickSenseOrder(repo, entryId, between))); + return; + } + if (!await repo.Entries.AnyAsyncEF(e => e.Id == entryId)) throw NotFoundException.ForType(entryId); + await harmonyChangeWriter.AddChange(new MoveSenseToEntryChange(senseId, entryId, await PickSenseOrder(repo, entryId, between))); + } + + public async Task SubmitMoveSense(Guid entryId, Guid senseId, BetweenPosition position, MoveKind kind = MoveKind.Reorder) + { + await using var repo = await repoFactory.CreateRepoAsync(); + if (kind == MoveKind.Reorder) + { + // the sense is gone or was reparented on this side: the reorder is moot, skip it + var sense = await repo.GetSense(senseId); + if (sense is null || sense.EntryId != entryId) return; + await harmonyChangeWriter.AddChange(new Changes.SetOrderChange(senseId, await PickSenseOrder(repo, entryId, position))); + return; + } + // no target check: a deleted target entry is fine, the move change then deletes the sense (delete wins) + await harmonyChangeWriter.AddChange(new MoveSenseToEntryChange(senseId, entryId, await PickSenseOrder(repo, entryId, position))); + } + + private static async Task PickSenseOrder(MiniLcmRepository repo, Guid entryId, BetweenPosition between) + { + return await OrderPicker.PickOrder(repo.Senses.Where(s => s.EntryId == entryId), between); + } + + public async Task DeleteSense(Guid entryId, Guid senseId) + { + await harmonyChangeWriter.AddChange(new DeleteChange(senseId)); + } +}