diff --git a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs index 5921709ec6..16fb039e23 100644 --- a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs @@ -14,15 +14,15 @@ public Task GetObject(Guid commitId, Guid entityId) } [JSInvokable] - public async ValueTask ProjectActivity( + public Task ProjectActivity( int skip, int take, string[]? authorFilterKeys = null, string[]? changeTypeKeys = null, ActivitySort sort = ActivitySort.NewestFirst) { - return await historyService.ProjectActivity(skip, take, - new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)).ToArrayAsync(); + return historyService.ProjectActivity(skip, take, + new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)); } [JSInvokable] diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs new file mode 100644 index 0000000000..258f4e1781 --- /dev/null +++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs @@ -0,0 +1,33 @@ +using LcmCrdt; +using Reinforced.Typings; +using Reinforced.Typings.Ast; +using Reinforced.Typings.Generators; + +namespace FwLiteShared.TypeGen; + +/// +/// Emits a `ChangeType` string-literal union and a `knownChangeTypes` array built from the registered CRDT +/// change types () and each type's serialized $type +/// discriminator (). Attached to the ChangeTypes +/// marker; suppresses the marker's own output. +/// +public class ChangeTypesCodeGenerator : ClassCodeGenerator +{ + public override RtClass GenerateNode(Type element, RtClass result, TypeResolver resolver) + { + var typeNames = LcmCrdtKernel.AllChangeTypes() + .Select(HistoryService.GetChangeTypeKeyFromType) + .Distinct() + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + var union = string.Concat(typeNames.Select(name => $"\n | '{name}'")); + var array = string.Concat(typeNames.Select(name => $"\n '{name}',")); + + Context.Location.CurrentNamespace.CompilationUnits.Add(new RtRaw( + $"export type ChangeType ={union};\n\nexport const knownChangeTypes = [{array}\n] as const satisfies readonly ChangeType[];\n")); + + // Suppress the marker class; only the raw union/array above is emitted. + return null!; + } +} diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs new file mode 100644 index 0000000000..13fc46155d --- /dev/null +++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs @@ -0,0 +1,10 @@ +// Namespace is LcmCrdt (not FwLiteShared.TypeGen) on purpose: Reinforced.Typings derives the generated +// file's path from the type's namespace, so this keeps the output at generated-types/LcmCrdt/ChangeTypes.ts. +namespace LcmCrdt; + +/// +/// Marker type only — no members. Reinforced.Typings (via ChangeTypesCodeGenerator) emits the +/// ChangeType string-literal union and knownChangeTypes array into ChangeTypes.ts from the +/// registered change types, so the frontend has a generated, exhaustive list of change $type values. +/// +internal sealed class ChangeTypes; diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 18bdb27bac..e991f20fec 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -183,6 +183,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(FwLiteConfig), typeof(HistoryLineItem), typeof(ProjectActivity), + typeof(ActivityChangeInfo), typeof(ActivityAuthor), typeof(ActivityChangeType), typeof(ActivityQuery), @@ -196,6 +197,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(AvailableUpdate), ], exportBuilder => exportBuilder.WithPublicProperties()); + builder.ExportAsClass().WithCodeGenerator(); + builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(false); diff --git a/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs b/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs index 5b35773e58..c03f0bb46e 100644 --- a/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DateTimeOffsetOrmParityTests.cs @@ -48,7 +48,7 @@ public async Task CommitTimestamp_SameUtcInstant_ThroughEfCoreAndLinq2db() // switches to EF this keeps the linq2db path (the one that needed the fix) under test. var l2dbTimestamp = await ctx.Set().Where(c => c.Id == commit.Id) .ToLinqToDB().Select(c => c.HybridDateTime.DateTime).FirstAsyncLinqToDB(); - var activityTimestamp = (await History.ProjectActivity(0, 1000).ToArrayAsync()).First(a => a.CommitId == commit.Id).Timestamp; + var activityTimestamp = (await History.ProjectActivity(0, 1000)).First(a => a.CommitId == commit.Id).Timestamp; var historyTimestamp = (await History.GetHistory(entryId).ToArrayAsync()).First(h => h.CommitId == commit.Id).Timestamp; foreach (var (name, value) in new[] diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs index dd53c90a69..cca02462ad 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -1,9 +1,11 @@ using LcmCrdt.Changes; +using LcmCrdt.Changes.Entries; using LcmCrdt.Utils; using Microsoft.EntityFrameworkCore; using MiniLcm.Tests.AutoFakerHelpers; using SIL.Harmony.Core; using Soenneker.Utils.AutoBogus; +using SystemTextJsonPatch; namespace LcmCrdt.Tests; @@ -60,7 +62,7 @@ public async Task ProjectActivity_FiltersByAuthorId() await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" }); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])); activities.Should().OnlyContain(a => a.Metadata.AuthorId == "alice-id"); activities.Should().HaveCountGreaterThanOrEqualTo(1); @@ -72,7 +74,7 @@ public async Task ProjectActivity_AuthorFilterKeys_ExcludesUnselectedAuthors() await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); await AddEntryCommit(new CommitMetadata { AuthorName = "FieldWorks" }); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])); activities.Should().NotContain(a => a.Metadata.AuthorName == "FieldWorks"); activities.Should().Contain(a => a.Metadata.AuthorName == "Alice"); @@ -85,7 +87,7 @@ public async Task ProjectActivity_SortsOldestFirst() await Task.Delay(5); await AddEntryCommit(new CommitMetadata { AuthorName = "Second", AuthorId = "second" }, "beta"); - var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.OldestFirst)).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.OldestFirst)); var firstIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "first"); var secondIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "second"); firstIndex.Should().BeGreaterThanOrEqualTo(0); @@ -99,7 +101,7 @@ public async Task ProjectActivity_SyncedNewestFirst_PlacesUnsyncedFirst() await SetSyncDate(syncedCommit.Id, new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero)); await AddEntryCommit(new CommitMetadata { AuthorName = "Unsynced", AuthorId = "unsynced" }, "unsynced-entry"); - var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedNewestFirst)).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedNewestFirst)); var commitAuthors = activities.Select(a => a.Metadata.AuthorId).Where(a => a is not null); commitAuthors.Should().ContainInOrder(["unsynced", "synced"]); } @@ -111,7 +113,7 @@ public async Task ProjectActivity_SyncedOldestFirst_PlacesUnsyncedLast() await SetSyncDate(syncedCommit.Id, new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero)); await AddEntryCommit(new CommitMetadata { AuthorName = "Unsynced", AuthorId = "unsynced" }, "unsynced-entry"); - var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedOldestFirst)).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 1000, new ActivityQuery(Sort: ActivitySort.SyncedOldestFirst)); var commitAuthors = activities.Select(a => a.Metadata.AuthorId).Where(a => a is not null); commitAuthors.Should().ContainInOrder(["synced", "unsynced"]); } @@ -123,7 +125,7 @@ public async Task ProjectActivity_PaginationRespectsFilters() await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" }); - var page = await Service.ProjectActivity(0, 1, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); + var page = await Service.ProjectActivity(0, 1, new ActivityQuery(AuthorFilterKeys: ["alice-id"])); page.Should().HaveCount(1); page[0].Metadata.AuthorId.Should().Be("alice-id"); @@ -135,7 +137,7 @@ public async Task ProjectActivity_FiltersByChangeTypeKeys() await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); await AddNewPublicationCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange)])).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange)])); activities.Should().OnlyContain(a => a.ChangeTypes.Contains(nameof(CreateEntryChange))); activities.Should().HaveCountGreaterThanOrEqualTo(1); @@ -148,11 +150,12 @@ public async Task ProjectActivity_ChangeTypeKeys_FiltersMultipleTypes() await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); await AddNewPublicationCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); await AddNewPartOfSpeechCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); - (await Service.ProjectActivity(0, 100, new ActivityQuery()).ToArrayAsync()) + (await Service.ProjectActivity(0, 100, new ActivityQuery())) .Should().Contain(a => a.ChangeTypes.Contains(nameof(CreatePartOfSpeechChange))); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange), nameof(CreatePublicationChange)])).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange), nameof(CreatePublicationChange)])); + activities.Should().HaveCountGreaterThanOrEqualTo(2); // the entry + publication commits, so the filter can't pass vacuously on an empty result activities.Should().OnlyContain(a => a.ChangeTypes.Any(t => t == nameof(CreateEntryChange) || t == nameof(CreatePublicationChange))); activities.Should().NotContain(a => a.ChangeTypes.Contains(nameof(CreatePartOfSpeechChange))); } @@ -162,11 +165,462 @@ public async Task ProjectActivity_IncludesChangeTypes() { await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); - var activity = await Service.ProjectActivity(0, 1).SingleAsync(); + var activity = (await Service.ProjectActivity(0, 1)).Single(); activity.ChangeTypes.Should().Contain("CreateEntryChange"); } + [Fact] + public async Task ProjectActivity_ChangeInfo_AddsHomographSubscriptToSubject_OnlyWhenAssigned() + { + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = new MultiString { ["en"] = "plain" } + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = new MultiString { ["en"] = "homograph" }, + HomographNumber = 2 + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "plain"); + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "homograph₂"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_SkipsAudioAndUsesAnotherWritingSystem() + { + // The headword lives only in "seh"; the alphabetically-first writing system is audio, which Entry.Headword() + // would surface (or report "(Unknown)") — the activity headword skips audio and falls through to a real value. + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = new MultiString + { + ["de-Zxxx-x-audio"] = "recording.wav", + ["seh"] = "nyumba" + } + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "nyumba"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_AppliesMorphTypeMarkers() + { + // Suffix morph types carry a leading "-" marker (seeded as a canonical morph type on project setup). + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = new MultiString { ["en"] = "ness" }, + MorphType = MorphTypeKind.Suffix + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "-ness"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_EmptyEntryHasNullSubject() + { + var entryId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry { Id = entryId }), + new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // No displayable headword: the subject and root-entry headword are null (the frontend renders its + // placeholder, never "(Unknown)"), but the change still resolves to its root entry. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].Subject == null + && a.ChangeInfo[0].RootEntryHeadword == null); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_UnlabellableSenseHasNullSubject() + { + var entryId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry { Id = entryId }), Meta()); + var senseId = await CreateSense(entryId, gloss: null, order: 1.0); + var patch = new JsonPatchDocument(); + patch.Replace(s => s.Gloss["en"], ""); + await DataModel.AddChange(ClientId, new JsonPatchChange(senseId, patch), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // Neither a headword nor a gloss to label the sense with: the subject is null (the frontend just + // omits the leading subject token), never a hardcoded English placeholder. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].Subject == null); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentEntryAndMovedSense() + { + var entryId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = entryId, + LexemeForm = new MultiString { ["en"] = "run" } + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + var senseId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateSenseChange(new Sense + { + Id = senseId, + Gloss = new MultiString { ["en"] = "to run" } + }, entryId), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + await DataModel.AddChange(ClientId, new SetOrderChange(senseId, 2.0), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The reorder names the parent entry as the subject and the moved sense's gloss as the target. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "to run"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesAVocabSubject() + { + var meta = new CommitMetadata { AuthorName = "A", AuthorId = "a" }; + await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(Guid.NewGuid(), new MultiString { ["en"] = "Verb" }), meta); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // A vocab object has no root entry, so no root-entry headword either. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "Verb" + && a.ChangeInfo[0].RootEntryId == null + && a.ChangeInfo[0].RootEntryHeadword == null); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesTheReferencedPartOfSpeech() + { + var meta = new CommitMetadata { AuthorName = "A", AuthorId = "a" }; + var posId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(posId, new MultiString { ["en"] = "Noun" }), meta); + var entryId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry { Id = entryId, LexemeForm = new MultiString { ["en"] = "run" } }), meta); + var senseId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateSenseChange(new Sense { Id = senseId, Gloss = new MultiString { ["en"] = "to run" } }, entryId), meta); + await DataModel.AddChange(ClientId, new SetPartOfSpeechChange(senseId, posId), meta); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The set-part-of-speech change names the sense as its subject and the assigned part of speech as its target. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].Target == "Noun"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_LoneSense_HasNoSenseNumber() + { + var entryId = await CreateEntry("run"); + await CreateSense(entryId, gloss: "to run", order: 1.0); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // A lone sense needs no number (nothing to disambiguate): "run · Added sense to run". + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "to run"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_MultipleSenses_NumberedPositionally() + { + var entryId = await CreateEntry("run"); + await CreateSense(entryId, gloss: "to run", order: 1.0); + await CreateSense(entryId, gloss: "a jog", order: 2.0); + await CreateSense(entryId, gloss: null, order: 3.0); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // With more than one sense every sense is numbered by position (like FieldWorks), independent of the + // gloss — unique or not — shown as a subscript after the gloss, like a homograph number. An empty + // gloss shows the parenthesized position instead. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "to run₁"); + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "a jog₂"); + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "(3)"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_DeletedEntry_StillHasHeadword() + { + var entryId = await CreateEntry("run"); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(entryId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The deleted entry is gone from the projected tables; its label is recovered from its last snapshot. + activities.Should().Contain(a => a.ChangeTypes.Contains("delete:Entry") + && a.ChangeInfo[0].Subject == "run" + && a.ChangeInfo[0].RootEntryHeadword == "run"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_DeletedSense_StillHasLabel_WithoutPosition() + { + var entryId = await CreateEntry("run"); + var senseId = await CreateSense(entryId, gloss: "to run", order: 1.0); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(senseId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // Recovered from its snapshot; absent from the live sibling list, so labeled by gloss with no number. + activities.Should().Contain(a => a.ChangeTypes.Contains("delete:Sense") + && a.ChangeInfo[0].Subject == "run › to run"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesSemanticDomainSubject() + { + var meta = Meta(); + await DataModel.AddChange(ClientId, new CreateSemanticDomainChange(Guid.NewGuid(), new MultiString { ["en"] = "Food" }, "5.2"), meta); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "5.2 Food"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesPublicationSubject() + { + await AddNewPublicationCommit(Meta(), "Main Dictionary"); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Main Dictionary"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesComplexFormTypeSubject() + { + await DataModel.AddChange(ClientId, new CreateComplexFormType(Guid.NewGuid(), new MultiString { ["en"] = "Compound" }), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Compound"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesMorphTypeSubject() + { + // Morph types are seeded on project setup; patch a canonical one so the change resolves through the MorphType bucket. + var suffixId = CanonicalMorphTypes.All[MorphTypeKind.Suffix].Id; + var patch = new JsonPatchDocument(); + patch.Replace(m => m.SecondaryOrder, 99); + await DataModel.AddChange(ClientId, new JsonPatchChange(suffixId, patch), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "suffix"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesExampleSentenceSubject() + { + var entryId = await CreateEntry("run"); + var senseId = await CreateSense(entryId, gloss: "to run", order: 1.0); + await DataModel.AddChange(ClientId, new CreateExampleSentenceChange(new ExampleSentence + { + Id = Guid.NewGuid(), + Sentence = new() { ["en"] = new RichString("I run daily") } + }, senseId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // An example resolves to its parent sense's subject and its root entry (id + headword). + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].RootEntryHeadword == "run"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesComplexFormComponentSubject_AndComponentTarget() + { + var complexFormId = await CreateEntry("blackbird"); + var componentId = await CreateEntry("bird"); + await AddComponent(complexFormId, componentId); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The link's subject is the complex form; the target is the component being linked. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "blackbird" + && a.ChangeInfo[0].RootEntryId == complexFormId + && a.ChangeInfo[0].Target == "bird"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_RemovedComponent_NamesEndpointsFromCreateChange() + { + var complexFormId = await CreateEntry("blackbird"); + var componentId = await CreateEntry("bird"); + var component = await AddComponent(complexFormId, componentId); + // Delete the link: it leaves the projection, so its endpoints must come from the create change. + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(component.Id), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The delete still names the complex form (subject) and component (target) for orientation. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "blackbird" + && a.ChangeInfo[0].Target == "bird"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_SetComplexFormComponent_NamesComplexFormAndNewComponent() + { + var complexFormId = await CreateEntry("blackbird"); + var componentId = await CreateEntry("black"); + var newComponentId = await CreateEntry("bird"); + var component = await AddComponent(complexFormId, componentId); + await DataModel.AddChange(ClientId, SetComplexFormComponentChange.NewComponent(component.Id, newComponentId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "blackbird" + && a.ChangeInfo[0].Target == "bird"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_RemoveSemanticDomain_NamesRemovedDomain() + { + var entryId = await CreateEntry("run"); + var domainId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateSemanticDomainChange(domainId, new MultiString { ["en"] = "Food" }, "5.2"), Meta()); + var domain = await DataModel.GetLatest(domainId); + var senseId = await CreateSense(entryId, gloss: "to run", order: 1.0, semanticDomains: [domain!]); + await DataModel.AddChange(ClientId, new RemoveSemanticDomainChange(domainId, senseId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The remove change names the sense as subject and the removed domain as target. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].Target == "5.2 Food"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_RemovePublication_NamesRemovedPublication() + { + var entryId = await CreateEntry("run"); + var publicationId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreatePublicationChange(publicationId, new MultiString { ["en"] = "Main Dictionary" }), Meta()); + var publication = await DataModel.GetLatest(publicationId); + await DataModel.AddChange(ClientId, new AddPublicationChange(entryId, publication!), Meta()); + await DataModel.AddChange(ClientId, new RemovePublicationChange(entryId, publicationId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The remove change names the entry as subject and the removed publication as target. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "run" + && a.ChangeInfo[0].Target == "Main Dictionary"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_RemoveComplexFormType_NamesRemovedType() + { + var entryId = await CreateEntry("blackbird"); + var typeId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateComplexFormType(typeId, new MultiString { ["en"] = "Compound" }), Meta()); + var type = await DataModel.GetLatest(typeId); + await DataModel.AddChange(ClientId, new AddComplexFormTypeChange(entryId, type!), Meta()); + await DataModel.AddChange(ClientId, new RemoveComplexFormTypeChange(entryId, typeId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "blackbird" + && a.ChangeInfo[0].Target == "Compound"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentSenseAndMovedExample() + { + var entryId = await CreateEntry("run"); + var senseId = await CreateSense(entryId, gloss: "to run", order: 1.0); + var exampleId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateExampleSentenceChange(new ExampleSentence + { + Id = exampleId, + Sentence = new() { ["en"] = new RichString("I run") } + }, senseId), Meta()); + await DataModel.AddChange(ClientId, new SetOrderChange(exampleId, 2.0), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // Reordering an example names its parent sense as the subject and the example's root entry (id + headword). + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].RootEntryHeadword == "run"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_Reorder_NamesComplexFormAndMovedComponent() + { + var complexFormId = await CreateEntry("blackbird"); + var componentId = await CreateEntry("bird"); + var component = await AddComponent(complexFormId, componentId); + await DataModel.AddChange(ClientId, new SetOrderChange(component.Id, 2.0), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // Reordering a component names the complex form as subject and the moved component as target. + activities.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "blackbird" + && a.ChangeInfo[0].RootEntryId == complexFormId + && a.ChangeInfo[0].Target == "bird"); + } + + private static CommitMetadata Meta() => new() { AuthorName = "A", AuthorId = "a" }; + + private async Task CreateEntry(string headword) + { + var entryId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = entryId, + LexemeForm = new MultiString { ["en"] = headword } + }), Meta()); + return entryId; + } + + private async Task CreateSense(Guid entryId, string? gloss, double order, IList? semanticDomains = null) + { + var senseId = Guid.NewGuid(); + var sense = new Sense + { + Id = senseId, + Order = order, + Gloss = gloss is null ? new MultiString() : new MultiString { ["en"] = gloss }, + SemanticDomains = semanticDomains ?? [] + }; + await DataModel.AddChange(ClientId, new CreateSenseChange(sense, entryId), Meta()); + return senseId; + } + + private async Task AddComponent(Guid complexFormId, Guid componentId) + { + var component = ComplexFormComponent.FromEntries( + new Entry { Id = complexFormId }, + new Entry { Id = componentId }); + await DataModel.AddChange(ClientId, new AddEntryComponentChange(component), Meta()); + return component; + } + private async Task AddEntryCommit(CommitMetadata metadata, string? headword = null) { var entry = headword is null diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index 7ce2d17008..6ebd147bc5 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -123,7 +123,7 @@ await WithProjectFromTemplate(request, "fr", async (services, api) => var historyService = services.GetRequiredService(); // The imported template data is re-attributed to System and tagged, overriding the signed-in user. - var templateCommits = await historyService.ProjectActivity(0, 1000, new ActivityQuery()).ToArrayAsync(); + var templateCommits = await historyService.ProjectActivity(0, 1000, new ActivityQuery()); templateCommits.Should().NotBeEmpty(); templateCommits.Should().AllSatisfy(activity => { @@ -134,7 +134,7 @@ await WithProjectFromTemplate(request, "fr", async (services, api) => // A later edit keeps the signed-in user (not System) — the stamp is scoped to the template import. await api.CreateEntry(new() { LexemeForm = { ["fr"] = "post-template" } }); - var latest = (await historyService.ProjectActivity(0, 1, new ActivityQuery()).ToArrayAsync()).Single(); + var latest = (await historyService.ProjectActivity(0, 1, new ActivityQuery())).Single(); latest.Metadata.AuthorId.Should().Be("test-user-id"); latest.Metadata.AuthorName.Should().Be("Test User"); latest.Metadata[CommitHelpers.TemplateProp].Should().BeNull(); @@ -167,7 +167,7 @@ await WithProjectFromTemplate(request, "fr", async (services, api) => private static async Task LatestCommitAuthorId(HistoryService historyService) { - var latest = await historyService.ProjectActivity(skip: 0, take: 1).ToArrayAsync(); + var latest = await historyService.ProjectActivity(skip: 0, take: 1); return latest.Single().Metadata.AuthorId; } diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs new file mode 100644 index 0000000000..5422d6e5f0 --- /dev/null +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -0,0 +1,390 @@ +using System.Linq.Expressions; +using LcmCrdt.Changes; +using LcmCrdt.Changes.Entries; +using LcmCrdt.Data; +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.EntityFrameworkCore; +using MiniLcm.Models; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Db; + +namespace LcmCrdt; + +/// +/// Batch-resolves a human label (and the root entry) for each change in a page of activity, so summaries +/// can name the entry/sense/vocab object a change is about without a per-row lookup. Degradable: leaves +/// null for types it doesn't resolve (the frontend falls back to a type label). +/// Reads the projected snapshot tables, so labels reflect the current state; objects missing from the +/// projection (deleted) are recovered from their latest snapshot so a delete can still name its subject. +/// The display headword is the best non-audio alternative across writing systems with morph-type markers +/// applied (e.g. "-ness" for a suffix); it's null when there's no displayable headword (all empty/audio-only) +/// or the entry is missing entirely, and the frontend renders a translatable placeholder in that case. +/// +internal static class ActivityChangeInfoResolver +{ + public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities) + { + var entryIds = new HashSet(); + var senseIds = new HashSet(); + var exampleIds = new HashSet(); + var componentIds = new HashSet(); + var partOfSpeechIds = new HashSet(); + var semanticDomainIds = new HashSet(); + var publicationIds = new HashSet(); + var complexFormTypeIds = new HashSet(); + var morphTypeIds = new HashSet(); + + foreach (var change in activities.SelectMany(a => a.Changes)) + { + switch (BucketFor(change.Change.EntityType)) + { + case nameof(Entry): entryIds.Add(change.EntityId); break; + case nameof(Sense): senseIds.Add(change.EntityId); break; + case nameof(ExampleSentence): exampleIds.Add(change.EntityId); break; + case nameof(ComplexFormComponent): componentIds.Add(change.EntityId); break; + case nameof(PartOfSpeech): partOfSpeechIds.Add(change.EntityId); break; + case nameof(SemanticDomain): semanticDomainIds.Add(change.EntityId); break; + case nameof(Publication): publicationIds.Add(change.EntityId); break; + case nameof(ComplexFormType): complexFormTypeIds.Add(change.EntityId); break; + case nameof(MorphType): morphTypeIds.Add(change.EntityId); break; + } + + // Some changes name a referenced item only by id; fold those ids into the same batch-loads so we can label them. + switch (change.Change) + { + case SetPartOfSpeechChange { PartOfSpeechId: { } posId }: partOfSpeechIds.Add(posId); break; + case RemoveSemanticDomainChange r: semanticDomainIds.Add(r.SemanticDomainId); break; + case RemovePublicationChange r: publicationIds.Add(r.PublicationId); break; + case RemoveComplexFormTypeChange r: complexFormTypeIds.Add(r.ComplexFormTypeId); break; + case AddEntryComponentChange a: + entryIds.Add(a.ComponentEntryId); + // Also load the complex-form entry so we can name the subject when the CFC has been deleted + // (soft-deleted CFCs are absent from the projection dict, so the ComplexFormComponent case + // below falls back to this change's ComplexFormEntryId). + entryIds.Add(a.ComplexFormEntryId); + break; + case SetComplexFormComponentChange { ComponentEntryId: { } cid }: entryIds.Add(cid); break; + } + } + + // Walk down to the root entry: component → entry, example → sense → entry. Load both ends of a component link so we can name either. + var components = await LoadComponents(db, componentIds); + foreach (var component in components.Values) + { + entryIds.Add(component.ComplexFormEntryId); + entryIds.Add(component.ComponentEntryId); + } + + // A deleted component link is absent from the projection above, so "Removed component" would have no + // headwords. Recover its endpoints from its create change (AddEntryComponentChange carries both entry + // ids) — enough to name the complex form and component for basic orientation. + var deletedComponentIds = componentIds.Where(id => !components.ContainsKey(id)).ToHashSet(); + var deletedComponentEndpoints = await LoadComponentEndpointsFromCreate(db, deletedComponentIds); + foreach (var (complexFormEntryId, componentEntryId) in deletedComponentEndpoints.Values) + { + entryIds.Add(complexFormEntryId); + entryIds.Add(componentEntryId); + } + var examples = await LoadExamples(db, exampleIds); + await RecoverDeleted(db, exampleIds, examples); + foreach (var example in examples.Values) senseIds.Add(example.SenseId); + var senses = await LoadSenses(db, senseIds); + await RecoverDeleted(db, senseIds, senses); + foreach (var sense in senses.Values) entryIds.Add(sense.EntryId); + + // Sibling senses of every affected sense's entry, so a sense's subject can carry its 1-based position + // (and detect duplicate glosses) without a per-sense query. Ordered by Order to match the editor. + var sensesByEntry = await LoadSensesByEntry(db, senses.Values.Select(s => s.EntryId).ToHashSet()); + + var entries = await LoadEntries(db, entryIds); + await RecoverDeleted(db, entryIds, entries); + var partsOfSpeech = await LoadNamed(db, partOfSpeechIds, p => new PartOfSpeech { Id = p.Id, Name = p.Name }); + await RecoverDeleted(db, partOfSpeechIds, partsOfSpeech); + var semanticDomains = await LoadSemanticDomains(db, semanticDomainIds); + await RecoverDeleted(db, semanticDomainIds, semanticDomains); + var publications = await LoadNamed(db, publicationIds, p => new Publication { Id = p.Id, Name = p.Name }); + await RecoverDeleted(db, publicationIds, publications); + var complexFormTypes = await LoadNamed(db, complexFormTypeIds, c => new ComplexFormType { Id = c.Id, Name = c.Name }); + await RecoverDeleted(db, complexFormTypeIds, complexFormTypes); + var morphTypes = await LoadNamed(db, morphTypeIds, m => new MorphType { Id = m.Id, Kind = m.Kind, Name = m.Name }); + await RecoverDeleted(db, morphTypeIds, morphTypes); + + // Markers (e.g. suffix "-") for the display headword, keyed by morph-type kind; first wins on duplicates. + // Only needed to render an entry headword, so skip the load entirely when no entry is being resolved. + var morphLookup = entryIds.Count == 0 + ? new Dictionary() + : (await db.Set().Select(m => new MorphType { Kind = m.Kind, Prefix = m.Prefix, Postfix = m.Postfix }).ToListAsyncLinqToDB()) + .GroupBy(m => m.Kind) + .ToDictionary(g => g.Key, g => g.First()); + + string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry, morphLookup) : null; + + // The distinguishing label for a sense within its entry. FieldWorks numbers senses positionally and + // shows the number when an entry has more than one sense — independent of the gloss (unlike homograph + // numbers, which key off identical headwords). Rendered like a homograph number: >1 sense → + // "{gloss}{subscript number}"; when the gloss is empty, "({number})" — parenthesized so a bare + // position can't be mistaken for a gloss that IS a digit (a bare subscript would have nothing to + // attach to, and this string is data, so a translatable "no gloss" placeholder can't live here). + // A lone sense → its gloss, or "" when it has none (nothing to distinguish). + string SenseGlossPart(Sense sense) + { + var siblings = sensesByEntry.GetValueOrDefault(sense.EntryId) ?? []; + var index = siblings.FindIndex(s => s.Id == sense.Id); + var glossText = Label(sense.Gloss); + // A deleted sense (recovered from its snapshot) is absent from the live sibling list — it has no + // meaningful position, so no number. + if (index < 0) return glossText ?? ""; + var number = index + 1; + var multiple = siblings.Count > 1; + if (!string.IsNullOrEmpty(glossText)) return multiple ? glossText + Subscript(number) : glossText; + return multiple ? $"({number})" : ""; + } + + // "headword › senseLabel". Degrades to just the headword when the sense has nothing to distinguish it + // (a lone, gloss-less sense), and to null when there's neither a headword nor a distinguishing label + // (Subject is display data, so a translatable fallback has to come from the frontend, not here). + string? SenseLabel(string? headword, Sense sense) + { + var glossPart = SenseGlossPart(sense); + if (headword is null) return string.IsNullOrEmpty(glossPart) ? null : glossPart; + return string.IsNullOrEmpty(glossPart) ? headword : $"{headword} › {glossPart}"; + } + + return activities.Select(activity => activity with + { + ChangeInfo = activity.Changes.Select(change => Build(change, Headword)).ToList(), + }).ToArray(); + + ActivityChangeInfo Build(ChangeEntity change, Func headword) + { + var info = ResolveReorder(change); + if (info is null) + { + var (subject, rootEntryId) = ResolveSubject(change, headword); + info = new ActivityChangeInfo(subject, rootEntryId, TargetLabel(change)); + } + return info.RootEntryId is { } rootId ? info with { RootEntryHeadword = headword(rootId) } : info; + } + + // A reorder reads best as " · Reordered ": the subject is the parent whose list changed, the target is the moved item. + ActivityChangeInfo? ResolveReorder(ChangeEntity change) + { + switch (change.Change) + { + case Changes.SetOrderChange when senses.TryGetValue(change.EntityId, out var sense): + return new ActivityChangeInfo(Headword(sense.EntryId), sense.EntryId, SenseGlossPart(sense)); + case Changes.SetOrderChange when examples.TryGetValue(change.EntityId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): + return new ActivityChangeInfo(SenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId, null); + case Changes.SetOrderChange when components.TryGetValue(change.EntityId, out var comp): + return new ActivityChangeInfo(Headword(comp.ComplexFormEntryId), comp.ComplexFormEntryId, + entries.ContainsKey(comp.ComponentEntryId) ? Headword(comp.ComponentEntryId) : null); + default: + return null; + } + } + + (string? Subject, Guid? RootEntryId) ResolveSubject(ChangeEntity change, Func headword) + { + var id = change.EntityId; + switch (BucketFor(change.Change.EntityType)) + { + case nameof(Entry): + return (headword(id), id); + case nameof(Sense) when senses.TryGetValue(id, out var sense): + // Create-sense reads as an entry-level change ("headword · Added sense senseN"), so the + // subject is the parent entry's headword and the sense identifier goes to Target. + // Sense edits keep the "headword › senseLabel" subject so field changes read as sense-level. + if (change.Change is CreateSenseChange) + return (headword(sense.EntryId), sense.EntryId); + return (SenseLabel(headword(sense.EntryId), sense), sense.EntryId); + case nameof(ExampleSentence) when examples.TryGetValue(id, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): + return (SenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); + case nameof(ComplexFormComponent) when components.TryGetValue(id, out var component): + return (headword(component.ComplexFormEntryId), component.ComplexFormEntryId); + case nameof(ComplexFormComponent) when deletedComponentEndpoints.TryGetValue(id, out var ep): + // The CFC is deleted (absent from the projection); name the complex form from the endpoints + // recovered from its create change. Covers both "Removed component" and add-then-deleted. + return (headword(ep.ComplexFormEntryId), ep.ComplexFormEntryId); + case nameof(PartOfSpeech) when partsOfSpeech.TryGetValue(id, out var pos): + return (Label(pos.Name), null); + case nameof(SemanticDomain) when semanticDomains.TryGetValue(id, out var domain): + return (SemanticDomainLabel(domain), null); + case nameof(Publication) when publications.TryGetValue(id, out var publication): + return (Label(publication.Name), null); + case nameof(ComplexFormType) when complexFormTypes.TryGetValue(id, out var cft): + return (Label(cft.Name), null); + case nameof(MorphType) when morphTypes.TryGetValue(id, out var morphType): + return (Label(morphType.Name), null); + default: + return (null, null); + } + } + + // The item a change references only by id (the part of speech set, the domain/publication/type removed). + string? TargetLabel(ChangeEntity change) => change.Change switch + { + SetPartOfSpeechChange { PartOfSpeechId: { } id } when partsOfSpeech.TryGetValue(id, out var pos) => Label(pos.Name), + RemoveSemanticDomainChange r when semanticDomains.TryGetValue(r.SemanticDomainId, out var domain) => SemanticDomainLabel(domain), + RemovePublicationChange r when publications.TryGetValue(r.PublicationId, out var publication) => Label(publication.Name), + RemoveComplexFormTypeChange r when complexFormTypes.TryGetValue(r.ComplexFormTypeId, out var cft) => Label(cft.Name), + // Component links resolve the component being linked (the change's subject is the complex form). + AddEntryComponentChange a when entries.TryGetValue(a.ComponentEntryId, out var component) => DisplayHeadword(component, morphLookup), + SetComplexFormComponentChange { ComponentEntryId: { } cid } when entries.TryGetValue(cid, out var component) => DisplayHeadword(component, morphLookup), + // A deleted component link (e.g. "Removed component"): name the component from the recovered endpoints. + _ when change.Change.EntityType == typeof(ComplexFormComponent) + && deletedComponentEndpoints.TryGetValue(change.EntityId, out var ep) + && entries.TryGetValue(ep.ComponentEntryId, out var component) => DisplayHeadword(component, morphLookup), + // Sense-create pairs with the ResolveSubject override above: subject is the parent entry headword, + // target names the new sense ("gwa₁ · Added sense senseN" — SenseGlossPart falls back to a subscript + // when the gloss is empty, matching sense-edit summaries). + CreateSenseChange when senses.TryGetValue(change.EntityId, out var sense) => SenseGlossPart(sense), + _ => null + }; + } + + private static string BucketFor(Type entityType) => entityType.Name; + + // Deleted objects are dropped from the projected tables, so the loads below can't label them ("Deleted + // word" with no word). Recover any still-missing ids from their latest snapshot — a delete's own snapshot + // keeps every field, only DeletedAt is set. No-op in the common case where nothing is missing; when ids + // ARE missing (deleted objects visible on the page — rare and few), one Take(1) query per id beats + // loading each entity's whole snapshot history. + private static async Task RecoverDeleted(ICrdtDbContext db, HashSet ids, Dictionary loaded) + where T : class, IObjectWithId + { + foreach (var id in ids.Where(id => !loaded.ContainsKey(id))) + { + var snapshot = await ( + from commit in db.Commits.DefaultOrderDescending() + from s in db.Snapshots.InnerJoin(s => s.CommitId == commit.Id) + where s.EntityId == id + select s + ).FirstOrDefaultAsyncLinqToDB(); + if (snapshot?.Entity.DbObject is T entity) loaded[id] = entity; + } + } + + // Per-type projected loads: only the columns the resolver reads, so we skip hydrating heavy JSONB columns + // (definitions, notes, nested senses/components, pictures, …) that never feed a label. + + private static async Task> LoadEntries(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(e => ids.Contains(e.Id)) + .Select(e => new Entry + { + Id = e.Id, + CitationForm = e.CitationForm, + LexemeForm = e.LexemeForm, + MorphType = e.MorphType, + HomographNumber = e.HomographNumber, + }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(e => e.Id); + } + + private static async Task> LoadSenses(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(s => ids.Contains(s.Id)) + .Select(s => new Sense { Id = s.Id, EntryId = s.EntryId, Gloss = s.Gloss, Order = s.Order }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(s => s.Id); + } + + private static async Task>> LoadSensesByEntry(ICrdtDbContext db, HashSet entryIds) + { + if (entryIds.Count == 0) return []; + var loaded = await db.Set().Where(s => entryIds.Contains(s.EntryId)) + .Select(s => new Sense { Id = s.Id, EntryId = s.EntryId, Gloss = s.Gloss, Order = s.Order }) + .ToListAsyncLinqToDB(); + return loaded + .GroupBy(s => s.EntryId) + .ToDictionary(g => g.Key, g => g.OrderBy(s => s.Order).ThenBy(s => s.Id).ToList()); + } + + private static async Task> LoadExamples(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(e => ids.Contains(e.Id)) + .Select(e => new ExampleSentence { Id = e.Id, SenseId = e.SenseId }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(e => e.Id); + } + + // The endpoints of deleted component links, recovered from their create change (which carries both entry + // ids). Two steps: load the changes for these CFC ids, then read the ids off the AddEntryComponentChange. + private static async Task> LoadComponentEndpointsFromCreate( + ICrdtDbContext db, HashSet cfcIds) + { + if (cfcIds.Count == 0) return []; + var changeEntities = await db.Set>() + .Where(ce => cfcIds.Contains(ce.EntityId) + && Sql.Expr("json_extract({0}, '$.\"$type\"')", ce.Change) == nameof(AddEntryComponentChange)) + .ToListAsyncLinqToDB(); + return changeEntities + .Select(ce => ce.Change) + .OfType() + .GroupBy(c => c.EntityId) + .ToDictionary(g => g.Key, g => (g.First().ComplexFormEntryId, g.First().ComponentEntryId)); + } + + private static async Task> LoadComponents(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(c => ids.Contains(c.Id)) + .Select(c => new ComplexFormComponent + { + Id = c.Id, + ComplexFormEntryId = c.ComplexFormEntryId, + ComponentEntryId = c.ComponentEntryId, + }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(c => c.Id); + } + + private static async Task> LoadSemanticDomains(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(d => ids.Contains(d.Id)) + .Select(d => new SemanticDomain { Id = d.Id, Code = d.Code, Name = d.Name }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(d => d.Id); + } + + // Vocab objects the resolver only labels by name (part of speech, publication, complex-form type, morph type). + private static async Task> LoadNamed(ICrdtDbContext db, HashSet ids, Expression> project) + where T : class, IObjectWithId + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(o => ids.Contains(o.Id)).Select(project).ToListAsyncLinqToDB(); + return loaded.ToDictionary(o => o.Id); + } + + // Order by writing-system code so a multi-writing-system name resolves to the same alternative every time, matching Entry.Headword(). + private static string? Label(MultiString multiString) => + multiString.Values.OrderBy(kvp => kvp.Key.Code).Select(kvp => kvp.Value?.Trim()).FirstOrDefault(text => !string.IsNullOrEmpty(text)); + + // Best non-audio alternative across writing systems (ordered by code, matching Label()) with morph-type markers + // applied (e.g. "-ness" for a suffix); null when there's no displayable text. FieldWorks distinguishes same-spelled + // entries by a homograph number shown as a subscript, assigned (> 0) only on a collision. + private static string? DisplayHeadword(Entry entry, IReadOnlyDictionary morphLookup) + { + var headword = EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values + .Where(kvp => !kvp.Key.IsAudio) + .OrderBy(kvp => kvp.Key.Code) + .Select(kvp => kvp.Value?.Trim()) + .FirstOrDefault(text => !string.IsNullOrEmpty(text)); + if (string.IsNullOrEmpty(headword)) return null; + return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword; + } + + private static string Subscript(int number) => + new(number.ToString().Select(digit => (char)('₀' + (digit - '0'))).ToArray()); + + // The app shows a domain as "code name" (e.g. "5.2 Food"); the code alone is too cryptic to identify it. + private static string SemanticDomainLabel(SemanticDomain domain) => + Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code; +} diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index 3c002a4c18..131279adac 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -36,6 +36,16 @@ public static class ActivityFilterKeys public const string AuthorNamePrefix = "name:"; } +/// +/// Resolved display info for one change, so the frontend can name what the change is about. +/// is the entity the change is on (entry headword, "headword › gloss" for a sense, or a vocab object's name); +/// is a referenced item the change names only by id (e.g. the part of speech assigned, the semantic domain removed). +/// Both are null when unresolved — the frontend falls back to a type label. +/// is the display headword of , so the frontend can +/// group a commit's changes under one entry header; null when there's no root entry or no displayable headword. +/// +public record ActivityChangeInfo(string? Subject, Guid? RootEntryId, string? Target = null, string? RootEntryHeadword = null); + public record ProjectActivity( Guid CommitId, DateTimeOffset Timestamp, @@ -44,6 +54,8 @@ public record ProjectActivity( { public string ChangeName => HistoryService.ChangesNameHelper(Changes); public string[] ChangeTypes { get; } = Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct().ToArray(); + /// Resolved display info per change, parallel to by index. Set during enrichment. + public IReadOnlyList ChangeInfo { get; init; } = []; } public record ChangeContext( @@ -51,13 +63,14 @@ public record ChangeContext( int ChangeIndex, string ChangeName, IObjectWithId? Snapshot, + IObjectWithId? PreviousSnapshot, ICollection AffectedEntries) { - public ChangeContext(ChangeEntity change, IObjectWithId? snapshot, ICollection affectedEntries) - : this(change.CommitId, change.Index, HistoryService.ChangeNameHelper(change.Change), snapshot, affectedEntries) + public ChangeContext(ChangeEntity change, IObjectWithId? snapshot, IObjectWithId? previousSnapshot, ICollection affectedEntries) + : this(change.CommitId, change.Index, HistoryService.ChangeNameHelper(change.Change), snapshot, previousSnapshot, affectedEntries) { } - public string EntityType => Snapshot?.GetType().Name ?? "Unknown"; + public string EntityType => (Snapshot ?? PreviousSnapshot)?.GetType().Name ?? "Unknown"; } public record HistoryLineItem( @@ -131,7 +144,9 @@ public async Task ListActivityChangeTypes() return registeredTypes; } - public async IAsyncEnumerable ProjectActivity(int skip = 0, int take = 100, ActivityQuery? query = null) + // Returns a materialized page rather than streaming: ActivityChangeInfoResolver batch-loads labels + // across all changes in the page at once. + public async Task ProjectActivity(int skip = 0, int take = 100, ActivityQuery? query = null) { query ??= new ActivityQuery(); await using ICrdtDbContext dbContext = await dbContextFactory.CreateDbContextAsync(); @@ -143,10 +158,8 @@ from commit in commits.Skip(skip).Take(take) commit.HybridDateTime.DateTime, commit.ChangeEntities.ToList(), commit.Metadata); - await foreach (var projectActivity in queryable.ToLinqToDB().AsAsyncEnumerable()) - { - yield return projectActivity; - } + var activities = await queryable.ToLinqToDB().ToArrayAsyncLinqToDB(); + return await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities); } private static IQueryable ApplyActivityFilters(IQueryable commits, ActivityQuery query) @@ -220,7 +233,13 @@ private static IQueryable ApplyActivitySort(IQueryable commits, }; } - private static string GetChangeTypeKeyFromType(Type changeType) + /// + /// The serialized $type discriminator of a change type. Mirrors Harmony's own logic: generic/shared + /// changes (jsonPatch:, delete:, SetOrderChange:) define a custom static TypeName, while a dedicated + /// change class serializes under its CLR type name. Single source of truth — the ChangeTypes TS codegen + /// uses this too, so the generated list can't drift from the runtime discriminators. + /// + public static string GetChangeTypeKeyFromType(Type changeType) { var typeNameProp = changeType.GetProperty("TypeName", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.FlattenHierarchy); @@ -301,21 +320,41 @@ public async Task LoadChangeContext(Guid commitId, int changeInde // Use safe cast - some entity types like RemoteResource don't implement IObjectWithId var snapshot = await dataModel.GetAtCommit(commitId, change.EntityId) as IObjectWithId; + var previousSnapshot = await LoadPreviousSnapshot(crdtDbContext, commitId, change.EntityId); + + await ResolveSensePartOfSpeech(snapshot); + await ResolveSensePartOfSpeech(previousSnapshot); + + var affectedEntries = await GetAffectedEntryIds(change) + .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) + .ToArrayAsync(); + + return new ChangeContext(change, snapshot, previousSnapshot, affectedEntries); + } + /// The entity's state just before , i.e. the snapshot at the most recent + /// commit that affected it before this one. Null if this commit created the entity. + private async Task LoadPreviousSnapshot(ICrdtDbContext dbContext, Guid commitId, Guid entityId) + { + var affectingCommitIds = await dbContext.Commits + .Where(c => c.ChangeEntities.Any(ce => ce.EntityId == entityId)) + .DefaultOrderDescending() + .Select(c => c.Id) + .ToListAsyncLinqToDB(); + var index = affectingCommitIds.IndexOf(commitId); + if (index < 0 || index + 1 >= affectingCommitIds.Count) return null; + return await dataModel.GetAtCommit(affectingCommitIds[index + 1], entityId) as IObjectWithId; + } + + // Older sense snapshots didn't store the part-of-speech object, only its id; resolve it so previews can show a label. + private async Task ResolveSensePartOfSpeech(IObjectWithId? snapshot) + { if (snapshot is Sense sense && sense.PartOfSpeechId != sense.PartOfSpeech?.Id) { - // previously we didn't update the part of speech object on sense snapshots - // we do now, so we patch old snapshots sense.PartOfSpeech = sense.PartOfSpeechId.HasValue ? await dataModel.GetLatest(sense.PartOfSpeechId.Value) : null; } - - var affectedEntries = await GetAffectedEntryIds(change) - .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) - .ToArrayAsync(); - - return new ChangeContext(change, snapshot, affectedEntries); } private async Task GetCurrentOrLatestEntry(Guid entryId) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 160f6180f4..e51b8468c3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: '@microsoft/signalr': specifier: ^10.0.0 version: 10.0.0 + '@sanity/diff-match-patch': + specifier: ^3.2.0 + version: 3.2.0 fast-json-patch: specifier: ^3.1.1 version: 3.1.1 @@ -3066,6 +3069,10 @@ packages: cpu: [x64] os: [win32] + '@sanity/diff-match-patch@3.2.0': + resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} + engines: {node: '>=18.18'} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -10548,6 +10555,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@sanity/diff-match-patch@3.2.0': {} + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -11403,7 +11412,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.2.0)(lightningcss@1.31.1)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index d3b2a2d395..f36d84163b 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -111,6 +111,7 @@ "@lingui/core": "^5.6.1", "@microsoft/dotnet-js-interop": "^10.0.0", "@microsoft/signalr": "^10.0.0", + "@sanity/diff-match-patch": "^3.2.0", "fast-json-patch": "^3.1.1", "jsdom": "^26.1.0", "just-throttle": "^4.2.0", diff --git a/frontend/viewer/src/lib/activity/ActivityFilter.svelte b/frontend/viewer/src/lib/activity/ActivityFilter.svelte index 5c2698c5f6..83a076d00a 100644 --- a/frontend/viewer/src/lib/activity/ActivityFilter.svelte +++ b/frontend/viewer/src/lib/activity/ActivityFilter.svelte @@ -25,12 +25,15 @@ type MultiFilterSelection, } from './utils'; import AuthorLabel from './AuthorLabel.svelte'; + import type {Snippet} from 'svelte'; type Props = { filters?: ActivityFilters; + /** Optional right-aligned content rendered on the same row as the sort menu (e.g. the list-mode icon). */ + trailing?: Snippet; }; - let {filters = $bindable(createDefaultActivityFilters())}: Props = $props(); + let {filters = $bindable(createDefaultActivityFilters()), trailing}: Props = $props(); const historyService = useHistoryService(); @@ -168,23 +171,28 @@ - - - {#snippet child({props})} - - {/snippet} - - - {#each Object.values(ActivitySort) as sortOption (sortOption)} - filters.sort = sortOption} - class={cn(filters.sort === sortOption && 'bg-muted')}> - - {sortLabels[sortOption]} - - {/each} - - + +
+ + + {#snippet child({props})} + + {/snippet} + + + {#each Object.values(ActivitySort) as sortOption (sortOption)} + filters.sort = sortOption} + class={cn(filters.sort === sortOption && 'bg-muted')}> + + {sortLabels[sortOption]} + + {/each} + + + {#if trailing}{@render trailing()}{/if} +
diff --git a/frontend/viewer/src/lib/activity/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 3dc4673c04..e9304099bf 100644 --- a/frontend/viewer/src/lib/activity/ActivityItem.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItem.svelte @@ -1,5 +1,5 @@ -
+
{#if activity} -
+
+ {#if showClose && onClose} + + {/if} {$t`Author:`} @@ -70,7 +201,7 @@ - + {#if activity.metadata.extraMetadata['SyncDate']} @@ -97,6 +228,18 @@ {$t`Not synced`} {/if} + + + {#snippet child({props})} +
@@ -104,61 +247,166 @@ !!openHistoryId, (open) => (open ? undefined : openHistoryId = undefined)} id={openHistoryId} selectedCommitId={activity.commitId}/> {/if} - {#if changes} -
- {#key changes} - `${item.change.commitId}:${item.change.index}`}> - {#snippet children(changeWithContext)} - {@const {change, lazyContext} = changeWithContext} - {#await lazyContext} - -
- {:then context} -
-
- {context.changeName} - - {#if showHistoryButton} - - {/if} -
- - - - {$t`Preview`} - - {$t`Details`} - -
- - - - -
- {formatJsonForUi(change)} -
-
-
-
-
- {/await} - {/snippet} -
- {/key} -
+ {#if units} + {@render changeList(units)} {/if} {/if}
+ +{#snippet changeHeader(summaryEntry: ChangeFactWithSubject, remaining: number, historyId: string | undefined, entry: IEntry | undefined = undefined)} + {@const glyph = FACT_GLYPH[factCategory(summaryEntry.fact)]} +
+ + {#if glyph}{/if} + + + {#if remaining > 0}{$t`(+${remaining} more)`}{/if} + + + {#if (entry && !entry.deletedAt) || (showHistoryButton && historyId)} + + {#if entry && !entry.deletedAt} + + {/if} + {#if showHistoryButton && historyId} + + {/if} + + {/if} +
+{/snippet} + + +{#snippet treeCard(unit: TreeUnit, assembled: TreeAssembled)} +
+ {#if unit.headerFact} + {@render changeHeader(unit.headerFact, 0, unit.historyId, assembled.entry)} + {/if} + + + {$t`Preview`} + {$t`Details`} + +
+ + + {#if assembled.kind === 'senseTree'} + + {:else} + + {/if} + + + +
+ {formatJsonForUi(unit.changes.map(c => c.change))} +
+
+
+
+
+{/snippet} + + +{#snippet loadError(error: unknown)} +
+
+ + {$t`Failed to load this change`} +
+ {#if error instanceof Error && error.message} +
{error.message}
+ {/if} +
+{/snippet} + +{#snippet changeList(items: ActivityUnit[])} +
+ {#key items} + item.kind === 'single' + ? `${item.change.change.commitId}:${item.change.change.index}` + : `tree:${item.rootEntryId}`}> + {#snippet children(unit)} + {#if unit.kind === 'single'} + {@render changeCard(unit.change)} + {:else} + {#await unit.lazyAssembled} +
+ {:then assembled} + {#if assembled} + {@render treeCard(unit, assembled)} + {:else} + {#each unit.changes as change (change.change.index)} + {@render changeCard(change)} + {/each} + {/if} + {:catch error} + {@render loadError(error)} + {/await} + {/if} + {/snippet} +
+ {/key} +
+{/snippet} + +{#snippet changeCard(changeWithContext: ChangeWithLazyContext)} + {@const {change, lazyContext} = changeWithContext} + {@const summaryEntries = describeActivity([change], [activity.changeInfo[change.index]])} + {#await lazyContext} + +
+ {:then context} +
+ {#if summaryEntries.length} + {@render changeHeader(summaryEntries[0], summaryEntries.length - 1, context.snapshot?.id ?? context.previousSnapshot?.id, singleAffectedEntry(context))} + {:else} +
+ {context.changeName} +
+ {/if} + + + + {$t`Preview`} + + {$t`Details`} + +
+ + + + +
+ {formatJsonForUi(change)} +
+
+
+
+
+ {:catch error} + {@render loadError(error)} + {/await} +{/snippet} +