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)); + } +}