From cba8373fd037b3dd9c4aede572689a3b671974c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 12:47:01 +0000 Subject: [PATCH 01/11] Resolve activity change subjects server-side and generate the change-type list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ActivityChangeInfoResolver: one batched pass per activity page that labels each change with the entity it's about (entry headword with homograph subscript, 'headword › gloss' for senses, vocab object names), the referenced item it names only by id (part of speech set, semantic domain removed, component linked), and the root entry so the frontend can group a commit's changes. Deleted objects are recovered from their latest snapshot so a delete can still name its subject. ProjectActivity returns a materialized page (Task) of enriched record copies since the resolver batch-loads across it. ChangeContext gains PreviousSnapshot (the entity's state just before the commit) so the frontend can render true before/after diffs (#2170). A Reinforced.Typings generator emits a ChangeType union + knownChangeTypes array from the registered CRDT change types via the same discriminator helper the runtime uses, giving the frontend a generated, exhaustive list to guard summary coverage with. Part 1/6 of the activity redesign (#2434 shows the assembled result). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Vm9mEUzcfgX4oUPAbhcfsj --- .../Services/HistoryServiceJsInvokable.cs | 4 +- .../TypeGen/ChangeTypesCodeGenerator.cs | 33 ++ .../FwLiteShared/TypeGen/ChangeTypesMarker.cs | 10 + .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 3 + .../DateTimeOffsetOrmParityTests.cs | 2 +- .../HistoryServiceActivitySubjectTests.cs | 427 ++++++++++++++++++ .../HistoryServiceActivityTests.cs | 78 +--- .../HistoryServiceActivityTestsBase.cs | 98 ++++ .../FwLite/LcmCrdt.Tests/OpenProjectTests.cs | 6 +- .../LcmCrdt/ActivityChangeInfoResolver.cs | 396 ++++++++++++++++ backend/FwLite/LcmCrdt/HistoryService.cs | 73 ++- .../generated-types/LcmCrdt/ChangeTypes.ts | 141 ++++++ .../LcmCrdt/IActivityChangeInfo.ts | 13 + .../generated-types/LcmCrdt/IChangeContext.ts | 1 + .../LcmCrdt/IProjectActivity.ts | 2 + .../generated-types/LcmCrdt/index.ts | 2 + .../viewer/src/lib/history/HistoryView.svelte | 2 +- 17 files changed, 1202 insertions(+), 89 deletions(-) create mode 100644 backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs create mode 100644 backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs create mode 100644 backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts diff --git a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs index 5921709ec6..f1853d4f49 100644 --- a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs @@ -14,7 +14,7 @@ public Task GetObject(Guid commitId, Guid entityId) } [JSInvokable] - public async ValueTask ProjectActivity( + public async Task ProjectActivity( int skip, int take, string[]? authorFilterKeys = null, @@ -22,7 +22,7 @@ public async ValueTask ProjectActivity( ActivitySort sort = ActivitySort.NewestFirst) { return await historyService.ProjectActivity(skip, take, - new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)).ToArrayAsync(); + 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/HistoryServiceActivitySubjectTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs new file mode 100644 index 0000000000..9e1c6eb49c --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs @@ -0,0 +1,427 @@ +using LcmCrdt.Changes; +using LcmCrdt.Changes.Entries; +using LcmCrdt.Utils; +using SIL.Harmony.Core; +using SystemTextJsonPatch; + +namespace LcmCrdt.Tests; + +// Lower-importance coverage: how a change names the entity it's about (subject/target labels, homograph and +// sense numbering, deleted-object recovery). Split from the core ProjectActivity paging/filtering/sorting tests. +public class HistoryServiceActivitySubjectTests : HistoryServiceActivityTestsBase +{ + [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"); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs index dd53c90a69..1e65bdff5b 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -1,31 +1,11 @@ using LcmCrdt.Changes; -using LcmCrdt.Utils; -using Microsoft.EntityFrameworkCore; -using MiniLcm.Tests.AutoFakerHelpers; +using LcmCrdt.Changes.Entries; using SIL.Harmony.Core; -using Soenneker.Utils.AutoBogus; namespace LcmCrdt.Tests; -public class HistoryServiceActivityTests : IAsyncLifetime, IAsyncDisposable +public class HistoryServiceActivityTests : HistoryServiceActivityTestsBase { - private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(["en"])); - private MiniLcmApiFixture _fixture = null!; - - private HistoryService Service => _fixture.GetService(); - private DataModel DataModel => _fixture.DataModel; - private Guid ClientId => _fixture.GetService().ProjectData.ClientId; - - public async Task InitializeAsync() - { - _fixture = MiniLcmApiFixture.Create(); - await _fixture.InitializeAsync(); - } - - public async Task DisposeAsync() => await _fixture.DisposeAsync(); - - async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync(); - [Fact] public async Task ListActivityAuthors_ReturnsDistinctAuthorsWithCounts() { @@ -60,7 +40,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 +52,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 +65,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 +79,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 +91,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 +103,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 +115,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 +128,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,41 +143,8 @@ 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"); } - - private async Task AddEntryCommit(CommitMetadata metadata, string? headword = null) - { - var entry = headword is null - ? await AutoFaker.EntryReadyForCreation(_fixture.Api) - : new Entry { Id = Guid.NewGuid(), LexemeForm = new MultiString { ["en"] = headword } }; - return await DataModel.AddChange(ClientId, new CreateEntryChange(entry), metadata); - } - - private async Task AddNewPublicationCommit(CommitMetadata metadata, string publicationName = "Test Publication") - { - return await DataModel.AddChange(ClientId, new CreatePublicationChange(Guid.NewGuid(), new MultiString - { - ["en"] = publicationName - }), metadata); - } - - private async Task AddNewPartOfSpeechCommit(CommitMetadata metadata, string partOfSpeechName = "Test Part of Speech") - { - return await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(Guid.NewGuid(), new MultiString - { - ["en"] = partOfSpeechName - }), metadata); - } - - private async Task SetSyncDate(Guid commitId, DateTimeOffset syncDate) - { - var db = _fixture.DbContext; - var commit = await db.Set().SingleAsync(c => c.Id == commitId); - commit.SetSyncDate(syncDate); - db.Entry(commit).Property(c => c.Metadata).IsModified = true; - await db.SaveChangesAsync(); - } } diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs new file mode 100644 index 0000000000..82da6c9afa --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs @@ -0,0 +1,98 @@ +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; + +namespace LcmCrdt.Tests; + +public abstract class HistoryServiceActivityTestsBase : IAsyncLifetime, IAsyncDisposable +{ + private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(["en"])); + private MiniLcmApiFixture _fixture = null!; + + protected HistoryService Service => _fixture.GetService(); + protected DataModel DataModel => _fixture.DataModel; + protected Guid ClientId => _fixture.GetService().ProjectData.ClientId; + + public async Task InitializeAsync() + { + _fixture = MiniLcmApiFixture.Create(); + await _fixture.InitializeAsync(); + } + + public async Task DisposeAsync() => await _fixture.DisposeAsync(); + + async ValueTask IAsyncDisposable.DisposeAsync() => await DisposeAsync(); + + protected static CommitMetadata Meta() => new() { AuthorName = "A", AuthorId = "a" }; + + protected 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; + } + + protected 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; + } + + protected 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; + } + + protected async Task AddEntryCommit(CommitMetadata metadata, string? headword = null) + { + var entry = headword is null + ? await AutoFaker.EntryReadyForCreation(_fixture.Api) + : new Entry { Id = Guid.NewGuid(), LexemeForm = new MultiString { ["en"] = headword } }; + return await DataModel.AddChange(ClientId, new CreateEntryChange(entry), metadata); + } + + protected async Task AddNewPublicationCommit(CommitMetadata metadata, string publicationName = "Test Publication") + { + return await DataModel.AddChange(ClientId, new CreatePublicationChange(Guid.NewGuid(), new MultiString + { + ["en"] = publicationName + }), metadata); + } + + protected async Task AddNewPartOfSpeechCommit(CommitMetadata metadata, string partOfSpeechName = "Test Part of Speech") + { + return await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(Guid.NewGuid(), new MultiString + { + ["en"] = partOfSpeechName + }), metadata); + } + + protected async Task SetSyncDate(Guid commitId, DateTimeOffset syncDate) + { + var db = _fixture.DbContext; + var commit = await db.Set().SingleAsync(c => c.Id == commitId); + commit.SetSyncDate(syncDate); + db.Entry(commit).Property(c => c.Metadata).IsModified = true; + await db.SaveChangesAsync(); + } +} 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..67f97dce34 --- /dev/null +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -0,0 +1,396 @@ +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) + { + Span buffer = stackalloc char[11]; // widest int32, e.g. "-2147483648" + number.TryFormat(buffer, out var charsWritten); + for (var i = 0; i < charsWritten; i++) + buffer[i] = buffer[i] == '-' ? '₋' : (char)(buffer[i] - '0' + '₀'); + return buffer[..charsWritten].ToString(); + } + + // 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/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts new file mode 100644 index 0000000000..dcd13d42da --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts @@ -0,0 +1,141 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export type ChangeType = + | 'AddComplexFormTypeChange' + | 'AddEntryComponentChange' + | 'AddPublicationChange' + | 'AddSemanticDomainChange' + | 'AddTranslationChange' + | 'CreateCommentThreadChange' + | 'CreateComplexFormType' + | 'CreateCustomViewChange' + | 'CreateEntryChange' + | 'CreateExampleSentenceChange' + | 'CreateMorphTypeChange' + | 'CreatePartOfSpeechChange' + | 'CreatePublicationChange' + | 'CreateSemanticDomainChange' + | 'CreateSenseChange' + | 'CreateSensePictureChange' + | 'CreateUserCommentChange' + | 'CreateWritingSystemChange' + | 'EditCustomViewChange' + | 'EditUserCommentChange' + | 'MoveSenseToEntryChange' + | 'RemoveComplexFormTypeChange' + | 'RemovePublicationChange' + | 'RemoveSemanticDomainChange' + | 'RemoveSensePictureChange' + | 'RemoveTranslationChange' + | 'ReorderSensePictureChange' + | 'ReplacePublicationChange' + | 'ReplaceSemanticDomainChange' + | 'SetCommentThreadStatusChange' + | 'SetComplexFormComponentChange' + | 'SetFirstTranslationIdChange' + | 'SetMainPublicationChange' + | 'SetOrderChange:ComplexFormComponent' + | 'SetOrderChange:ExampleSentence' + | 'SetOrderChange:Sense' + | 'SetOrderChange:WritingSystem' + | 'SetPartOfSpeechChange' + | 'UpdateSensePictureChange' + | 'UpdateTranslationChange' + | 'create:pendingUpload' + | 'create:remote-resource' + | 'delete:CommentThread' + | 'delete:ComplexFormComponent' + | 'delete:ComplexFormType' + | 'delete:CustomView' + | 'delete:Entry' + | 'delete:ExampleSentence' + | 'delete:PartOfSpeech' + | 'delete:Publication' + | 'delete:RemoteResource' + | 'delete:SemanticDomain' + | 'delete:Sense' + | 'delete:UserComment' + | 'delete:WritingSystem' + | 'jsonPatch:ComplexFormType' + | 'jsonPatch:Entry' + | 'jsonPatch:ExampleSentence' + | 'jsonPatch:MorphType' + | 'jsonPatch:PartOfSpeech' + | 'jsonPatch:Publication' + | 'jsonPatch:SemanticDomain' + | 'jsonPatch:Sense' + | 'jsonPatch:WritingSystem' + | 'uploaded:RemoteResource'; + +export const knownChangeTypes = [ + 'AddComplexFormTypeChange', + 'AddEntryComponentChange', + 'AddPublicationChange', + 'AddSemanticDomainChange', + 'AddTranslationChange', + 'CreateCommentThreadChange', + 'CreateComplexFormType', + 'CreateCustomViewChange', + 'CreateEntryChange', + 'CreateExampleSentenceChange', + 'CreateMorphTypeChange', + 'CreatePartOfSpeechChange', + 'CreatePublicationChange', + 'CreateSemanticDomainChange', + 'CreateSenseChange', + 'CreateSensePictureChange', + 'CreateUserCommentChange', + 'CreateWritingSystemChange', + 'EditCustomViewChange', + 'EditUserCommentChange', + 'MoveSenseToEntryChange', + 'RemoveComplexFormTypeChange', + 'RemovePublicationChange', + 'RemoveSemanticDomainChange', + 'RemoveSensePictureChange', + 'RemoveTranslationChange', + 'ReorderSensePictureChange', + 'ReplacePublicationChange', + 'ReplaceSemanticDomainChange', + 'SetCommentThreadStatusChange', + 'SetComplexFormComponentChange', + 'SetFirstTranslationIdChange', + 'SetMainPublicationChange', + 'SetOrderChange:ComplexFormComponent', + 'SetOrderChange:ExampleSentence', + 'SetOrderChange:Sense', + 'SetOrderChange:WritingSystem', + 'SetPartOfSpeechChange', + 'UpdateSensePictureChange', + 'UpdateTranslationChange', + 'create:pendingUpload', + 'create:remote-resource', + 'delete:CommentThread', + 'delete:ComplexFormComponent', + 'delete:ComplexFormType', + 'delete:CustomView', + 'delete:Entry', + 'delete:ExampleSentence', + 'delete:PartOfSpeech', + 'delete:Publication', + 'delete:RemoteResource', + 'delete:SemanticDomain', + 'delete:Sense', + 'delete:UserComment', + 'delete:WritingSystem', + 'jsonPatch:ComplexFormType', + 'jsonPatch:Entry', + 'jsonPatch:ExampleSentence', + 'jsonPatch:MorphType', + 'jsonPatch:PartOfSpeech', + 'jsonPatch:Publication', + 'jsonPatch:SemanticDomain', + 'jsonPatch:Sense', + 'jsonPatch:WritingSystem', + 'uploaded:RemoteResource', +] as const satisfies readonly ChangeType[]; + +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts new file mode 100644 index 0000000000..afd3ccc7d3 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export interface IActivityChangeInfo +{ + subject?: string; + rootEntryId?: string; + target?: string; + rootEntryHeadword?: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts index f93756ea9e..5088ebaa89 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts @@ -12,6 +12,7 @@ export interface IChangeContext changeIndex: number; changeName: string; snapshot?: IObjectWithId; + previousSnapshot?: IObjectWithId; affectedEntries: IEntry[]; entityType: string; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts index ad0681ad65..8534c0a289 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts @@ -5,6 +5,7 @@ import type {IChangeEntity} from '../SIL/Harmony/Core/IChangeEntity'; import type {ICommitMetadata} from '../SIL/Harmony/Core/ICommitMetadata'; +import type {IActivityChangeInfo} from './IActivityChangeInfo'; export interface IProjectActivity { @@ -14,5 +15,6 @@ export interface IProjectActivity metadata: ICommitMetadata; changeName: string; changeTypes: string[]; + changeInfo: IActivityChangeInfo[]; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts index e4fe82c420..713b3e6c39 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts @@ -6,5 +6,7 @@ export * from './UserProjectRole' export * from './IChangeContext' export * from './IActivityAuthor' export * from './IActivityChangeType' +export * from './IActivityChangeInfo' export * from './IActivityQuery' export * from './ActivitySort' +export * from './ChangeTypes' diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index fc2c310b80..ca3dedb9bd 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -103,7 +103,7 @@
{#if record} - + {/if}
From 210a622d87acfc971d4c22af81f7eace81cc6cc9 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 21 Jul 2026 16:22:09 +0200 Subject: [PATCH 02/11] Improve type coverage, test coverage and type safety. --- backend/Directory.Packages.props | 6 +- .../FwLiteShared/TypeGen/ChangeTypeMarker.cs | 14 + .../FwLiteShared/TypeGen/ChangeTypesMarker.cs | 10 - .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 8 +- ...ActivityChangeInfoResolverCoverageTests.cs | 61 +++ ...tyChangeInfoResolverExampleSnippetTests.cs | 99 ++++ .../LcmCrdt.Tests/Changes/UseChangesTests.cs | 21 + .../HistoryServiceActivitySubjectTests.cs | 496 +++++++++++++----- .../HistoryServiceActivityTests.cs | 1 - .../HistoryServiceActivityTestsBase.cs | 35 +- .../LcmCrdt/ActivityChangeInfoResolver.cs | 307 ++++++++--- backend/FwLite/LcmCrdt/HistoryService.cs | 53 +- frontend/viewer/src/lib/activity/utils.ts | 3 +- .../LcmCrdt/{ChangeTypes.ts => ChangeType.ts} | 2 + .../LcmCrdt/IActivityChangeType.ts | 4 +- .../generated-types/LcmCrdt/IActivityQuery.ts | 3 +- .../LcmCrdt/IProjectActivity.ts | 3 +- .../generated-types/LcmCrdt/index.ts | 2 +- 18 files changed, 889 insertions(+), 239 deletions(-) create mode 100644 backend/FwLite/FwLiteShared/TypeGen/ChangeTypeMarker.cs delete mode 100644 backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs rename frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/{ChangeTypes.ts => ChangeType.ts} (98%) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index ac05ebb181..10bf027b84 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -109,9 +109,9 @@ - - - + + + diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypeMarker.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypeMarker.cs new file mode 100644 index 0000000000..e5f36fa3cc --- /dev/null +++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypeMarker.cs @@ -0,0 +1,14 @@ +// Namespace is LcmCrdt (not FwLiteShared.TypeGen) on purpose: Reinforced.Typings derives the generated +// file's folder from the type's namespace and the file name from the type name, so this places the output +// at generated-types/LcmCrdt/ChangeType.ts. Named to match the emitted union so properties can reference it. +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace LcmCrdt; +#pragma warning restore IDE0130 // Namespace does not match folder structure + + +/// +/// Marker type only — no members. Reinforced.Typings (via ChangeTypesCodeGenerator) replaces it with a +/// ChangeType string-literal union plus a knownChangeTypes array built from the registered change +/// types, so the frontend has a generated, exhaustive list of change $type values. +/// +internal sealed class ChangeType; diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs deleted file mode 100644 index 13fc46155d..0000000000 --- a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs +++ /dev/null @@ -1,10 +0,0 @@ -// 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 8f071b43df..4a7eed1571 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -199,7 +199,13 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(HarmonyResource), ], exportBuilder => exportBuilder.WithPublicProperties()); - builder.ExportAsClass().WithCodeGenerator(); + builder.ExportAsClass().WithCodeGenerator(); + builder.ExportAsInterface() + .WithProperty(a => a.ChangeTypes, p => p.Type()); + builder.ExportAsInterface() + .WithProperty(t => t.Key, p => p.Type()); + builder.ExportAsInterface() + .WithProperty(q => q.ChangeTypeKeys, p => p.Type()); builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); diff --git a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs new file mode 100644 index 0000000000..5a9ce73212 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions.Execution; +using LcmCrdt.Tests.Changes; + +namespace LcmCrdt.Tests; + +public class ActivityChangeInfoResolverCoverageTests : HistoryServiceActivityTestsBase +{ + // Entity types for which a "subject" is not currently supported + private static readonly Dictionary IntentionallyDegraded = new() + { + ["RemoteResource"] = "Media upload plumbing (Harmony type, not IObjectWithId); the filename lives on " + + "the resource service, not a projected table, and only one of its change types carries it inline.", + }; + + [Fact] + public async Task EveryChangeEntityType_ResolvesASubject_OrIsIntentionallyDegraded() + { + await DataModel.AddChanges(ClientId, UseChangesTests.AllChangesInDependencyOrder()); + var activities = await Service.ProjectActivity(0, take: 1000, new ActivityQuery()); + + // entity type name -> did EVERY change on that type resolve a non-null Subject (coverage), and did ANY + // (degraded-allowlist rot-check). The catalogue populates a displayable label on every object, so a + // handled type should resolve for all of its changes, not just one. + var allResolved = new Dictionary(); + var anyResolved = new Dictionary(); + foreach (var activity in activities) + { + for (var i = 0; i < activity.Changes.Count; i++) + { + var name = EntityTypeName(activity.Changes[i].Change.EntityType); + var hasSubject = activity.ChangeInfo[i].Subject is not null; + allResolved[name] = allResolved.GetValueOrDefault(name, true) && hasSubject; + anyResolved[name] = anyResolved.GetValueOrDefault(name) || hasSubject; + } + } + + using var _ = new AssertionScope(); + foreach (var (name, resolved) in allResolved) + { + if (IntentionallyDegraded.ContainsKey(name)) continue; + resolved.Should().BeTrue($"a change on entity type '{name}' resolved no Subject in the activity feed; " + + "give it a Subject arm in ActivityChangeInfoResolver (with an example test) " + + $"or list it in {nameof(IntentionallyDegraded)} with a reason"); + } + + // Keep the allowlist from rotting: each listed type must actually be exercised, and must genuinely + // degrade. If ANY change on it starts resolving a Subject, it's no longer fully degraded — drop it from + // the list (a type typically gains resolution one change at a time, so check "any", not "all"). + foreach (var name in IntentionallyDegraded.Keys) + { + anyResolved.Should().ContainKey(name, + $"'{name}' is listed as intentionally degraded but no such change was exercised"); + anyResolved.GetValueOrDefault(name).Should().BeFalse( + $"'{name}' is listed as intentionally degraded but now resolves a Subject; remove it from {nameof(IntentionallyDegraded)}"); + } + } + + // Entity type name without the CLR generic-arity suffix, so a generic entity (RemoteResource) + // reads as "RemoteResource". The resolved entity types are all non-generic, so stripping is harmless there. + private static string EntityTypeName(Type entityType) => entityType.Name.Split('`')[0]; +} diff --git a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs new file mode 100644 index 0000000000..a5b6d4206b --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs @@ -0,0 +1,99 @@ +using System.Globalization; + +namespace LcmCrdt.Tests; + +public class ActivityChangeInfoResolverExampleSnippetTests +{ + private static RichMultiString Sentence(string text) => new() { ["en"] = new RichString(text) }; + + private const int Budget = ActivityChangeInfoResolver.TextSnippetBudget; + + [Theory] + // Fits within the budget → returned whole, no ellipsis. + [InlineData("I run every morning", "I run every morning")] // 19 chars + [InlineData("12345678901234567890", "12345678901234567890")] // exactly 20 + // Over budget → cut back to the last space in the kept window (so a word isn't split), then ellipsis. + [InlineData("The quick brown fox jumps over the lazy dog", "The quick brown fox…")] // 4 words + // A single long token with no space to break on → hard grapheme cut at the budget. + [InlineData("Supercalifragilisticexpialidocious is long", "Supercalifragilistic…")] + public void ExampleSnippet_TruncatesSpacedText(string input, string expected) + { + ActivityChangeInfoResolver.ExampleSnippet(Sentence(input)).Should().Be(expected); + } + + [Fact] + public void ExampleSnippet_RespectsWritingSystemOrder() + { + // "fr" is configured first, so it wins even though "en" sorts earlier by code. + var sentence = new RichMultiString { ["en"] = new RichString("hello"), ["fr"] = new RichString("bonjour") }; + var wsOrder = new Dictionary { ["fr"] = 0, ["en"] = 1 }; + ActivityChangeInfoResolver.ExampleSnippet(sentence, wsOrder).Should().Be("bonjour"); + } + + [Fact] + public void ExampleSnippet_FallsBackToWritingSystemCode_WhenNoOrderGiven() + { + // With no project writing-system order supplied, selection falls back to WS code; "en" sorts before "fr". + var sentence = new RichMultiString { ["fr"] = new RichString("bonjour"), ["en"] = new RichString("hello") }; + ActivityChangeInfoResolver.ExampleSnippet(sentence).Should().Be("hello"); + } + + [Fact] + public void ExampleSnippet_FlattensSpansAndCollapsesWhitespace() + { + var sentence = new RichMultiString + { + ["en"] = new RichString([ + new RichSpan { Text = "I ", Bold = RichTextToggle.On }, + new RichSpan { Text = "run\n\n daily" }, + ]), + }; + ActivityChangeInfoResolver.ExampleSnippet(sentence).Should().Be("I run daily"); + } + + [Fact] + public void ExampleSnippet_SkipsEmptyWritingSystems() + { + var sentence = new RichMultiString { ["en"] = new RichString(" "), ["seh"] = new RichString("nyumba") }; + ActivityChangeInfoResolver.ExampleSnippet(sentence).Should().Be("nyumba"); + } + + [Fact] + public void ExampleSnippet_NoDisplayableText_ReturnsNull() + { + ActivityChangeInfoResolver.ExampleSnippet(new RichMultiString()).Should().BeNull(); + ActivityChangeInfoResolver.ExampleSnippet(new RichMultiString { ["en"] = new RichString("") }).Should().BeNull(); + } + + [Fact] + public void ExampleSnippet_NoSpaceScript_HardCutsAtGraphemeBoundary() + { + // CJK has no inter-word spaces; there's nothing to break on, so it's a clean cut at the budget. + var text = new string('好', 25); + ActivityChangeInfoResolver.ExampleSnippet(Sentence(text)).Should().Be(new string('好', 20) + "…"); + } + + [Fact] + public void ExampleSnippet_CombiningMarks_NotSplitMidGrapheme() + { + // "a" + combining acute accent = one grapheme, two chars. Truncation must count graphemes, not chars, + // and never cut between the base and its mark. + var text = string.Concat(Enumerable.Repeat("á", Budget + 5)); // 25 graphemes, 50 chars + var result = ActivityChangeInfoResolver.ExampleSnippet(Sentence(text)); + + result.Should().EndWith("…"); + var kept = result[..^1]; + new StringInfo(kept).LengthInTextElements.Should().Be(Budget); + kept.Length.Should().Be(Budget * 2); // 20 graphemes × 2 chars each, i.e. no pair was split + } + + [Fact] + public void ExampleSnippet_SurrogatePairs_NotSplit() + { + // Emoji outside the BMP are surrogate pairs (two chars, one grapheme); a char-based cut would corrupt them. + var text = string.Concat(Enumerable.Repeat("😀", count: Budget + 5)); + var result = ActivityChangeInfoResolver.ExampleSnippet(Sentence(text)); + + result.Should().Be(string.Concat(Enumerable.Repeat("😀", Budget)) + "…"); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs index c758631717..01db0c5346 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs @@ -134,6 +134,27 @@ public void ChangesIncludeAllExpectedChangeTypes() } } + // Every change type, dependency-ordered so it can be applied in one pass. Shared with + // ActivityChangeInfoResolverCoverageTests, which drives the whole catalogue through the activity feed. + internal static IReadOnlyList AllChangesInDependencyOrder() + { + var remaining = GetAllChanges().ToList(); + var ordered = new List(remaining.Count); + while (remaining.Count > 0) + { + var ready = remaining + .Where(c => c.Dependencies is null || c.Dependencies.All(d => ordered.Contains(d))) + .ToList(); + if (ready.Count == 0) throw new InvalidOperationException("Cyclic or unsatisfiable change dependencies"); + foreach (var c in ready) + { + ordered.Add(c.Change); + remaining.Remove(c); + } + } + return ordered; + } + private record ChangeWithDependencies(IChange Change, IEnumerable? Dependencies = null); private static IEnumerable GetAllChanges() diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs index 9e1c6eb49c..c1726e6fd7 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs @@ -1,15 +1,14 @@ using LcmCrdt.Changes; using LcmCrdt.Changes.Entries; -using LcmCrdt.Utils; using SIL.Harmony.Core; using SystemTextJsonPatch; namespace LcmCrdt.Tests; -// Lower-importance coverage: how a change names the entity it's about (subject/target labels, homograph and -// sense numbering, deleted-object recovery). Split from the core ProjectActivity paging/filtering/sorting tests. public class HistoryServiceActivitySubjectTests : HistoryServiceActivityTestsBase { + // --- Subject/label formatting: how a headword or gloss is rendered into a subject/target string. --- + [Fact] public async Task ProjectActivity_ChangeInfo_AddsHomographSubscriptToSubject_OnlyWhenAssigned() { @@ -67,6 +66,36 @@ public async Task ProjectActivity_ChangeInfo_AppliesMorphTypeMarkers() activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "-ness"); } + [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_EmptyEntryHasNullSubject() { @@ -103,94 +132,151 @@ public async Task ProjectActivity_ChangeInfo_UnlabellableSenseHasNullSubject() && a.ChangeInfo[0].Subject == null); } + // --- Vocab objects name themselves by their display name, and recover that label after deletion. --- + [Fact] - public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentEntryAndMovedSense() + public async Task ProjectActivity_ChangeInfo_NamesPartOfSpeechSubject_EvenAfterDelete() { - 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 id = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(id, new MultiString { ["en"] = "Verb" }), Meta()); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // A vocab object names itself by its display name and has no root entry (so no root-entry headword either). + live.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "Verb" + && a.ChangeInfo[0].RootEntryId == null + && a.ChangeInfo[0].RootEntryHeadword == null); - // 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"); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); + + var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // The delete drops it from the projection; its label is recovered from the last snapshot. + afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:PartOfSpeech") + && a.ChangeInfo[0].Subject == "Verb"); } [Fact] - public async Task ProjectActivity_ChangeInfo_NamesAVocabSubject() + public async Task ProjectActivity_ChangeInfo_NamesSemanticDomainSubject_EvenAfterDelete() { - var meta = new CommitMetadata { AuthorName = "A", AuthorId = "a" }; - await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(Guid.NewGuid(), new MultiString { ["en"] = "Verb" }), meta); + var id = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateSemanticDomainChange(id, new MultiString { ["en"] = "Food" }, "5.2"), Meta()); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // A domain shows as "code name". + live.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "5.2 Food" + && a.ChangeInfo[0].RootEntryId == null); - // 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); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); + + var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); + afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:SemanticDomain") + && a.ChangeInfo[0].Subject == "5.2 Food"); } [Fact] - public async Task ProjectActivity_ChangeInfo_NamesTheReferencedPartOfSpeech() + public async Task ProjectActivity_ChangeInfo_NamesPublicationSubject_EvenAfterDelete() { - 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 id = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreatePublicationChange(id, new MultiString { ["en"] = "Main Dictionary" }), Meta()); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); + live.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "Main Dictionary" + && a.ChangeInfo[0].RootEntryId == null); - // 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"); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); + + var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); + afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:Publication") + && a.ChangeInfo[0].Subject == "Main Dictionary"); } [Fact] - public async Task ProjectActivity_ChangeInfo_LoneSense_HasNoSenseNumber() + public async Task ProjectActivity_ChangeInfo_NamesComplexFormTypeSubject_EvenAfterDelete() { - var entryId = await CreateEntry("run"); - await CreateSense(entryId, gloss: "to run", order: 1.0); + var id = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateComplexFormType(id, new MultiString { ["en"] = "Compound" }), Meta()); - var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); + live.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "Compound" + && a.ChangeInfo[0].RootEntryId == null); - // 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"); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); + + var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); + afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:ComplexFormType") + && a.ChangeInfo[0].Subject == "Compound"); } [Fact] - public async Task ProjectActivity_ChangeInfo_MultipleSenses_NumberedPositionally() + public async Task ProjectActivity_ChangeInfo_NamesWritingSystemSubject_EvenAfterDelete() { - 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 id = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateWritingSystemChange(new WritingSystem + { + Id = id, + WsId = "fr", + Name = "French", + Abbreviation = "fr", + Font = "Arial", + Type = WritingSystemType.Vernacular + }, id, 0), Meta()); + + var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // A writing system names itself by its plain display name and has no root entry. + live.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "French" + && a.ChangeInfo[0].RootEntryId == null); + + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); + + var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // The delete drops it from the projection; its name is recovered from the last snapshot. + afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:WritingSystem") + && a.ChangeInfo[0].Subject == "French"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_NamesCustomViewSubject_EvenAfterDelete() + { + var id = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateCustomViewChange(id, new CustomView + { + Id = id, + Name = "My View", + Base = ViewBase.FwLite + }), Meta()); + + var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); + live.Should().Contain(a => a.ChangeInfo.Count == 1 + && a.ChangeInfo[0].Subject == "My View" + && a.ChangeInfo[0].RootEntryId == null); + + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); + + var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); + afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:CustomView") + && a.ChangeInfo[0].Subject == "My View"); + } + + [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()); - // 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)"); + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "suffix"); } + // --- Deleted entries and senses recover their labels from the last snapshot. --- + [Fact] public async Task ProjectActivity_ChangeInfo_DeletedEntry_StillHasHeadword() { @@ -214,74 +300,137 @@ public async Task ProjectActivity_ChangeInfo_DeletedSense_StillHasLabel_WithoutP 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. + // Removing a sense mirrors adding one: the entry is the subject, the removed sense the target. + // Recovered from its snapshot; absent from the live sibling list, so the target gloss carries no number. activities.Should().Contain(a => a.ChangeTypes.Contains("delete:Sense") - && a.ChangeInfo[0].Subject == "run › to run"); + && a.ChangeInfo[0].Subject == "run" + && a.ChangeInfo[0].Target == "to run"); } + // --- Reference changes name the entity holding the reference (subject) and the referenced object (target), + // grouped by referenced type. Add and Remove of the same reference name the same subject/target, so an + // "Added"/"Removed" pair reads as inverses in the activity feed. --- + [Fact] - public async Task ProjectActivity_ChangeInfo_NamesSemanticDomainSubject() + public async Task ProjectActivity_ChangeInfo_NamesTheReferencedPartOfSpeech() { - var meta = Meta(); - await DataModel.AddChange(ClientId, new CreateSemanticDomainChange(Guid.NewGuid(), new MultiString { ["en"] = "Food" }, "5.2"), meta); + 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()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "5.2 Food"); + // 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_NamesPublicationSubject() + public async Task ProjectActivity_ChangeInfo_AddSemanticDomain_NamesAddedDomain() { - await AddNewPublicationCommit(Meta(), "Main Dictionary"); + var entryId = await CreateEntry("run"); + var senseId = await CreateSense(entryId, gloss: "to run", order: 1.0); + var domainId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateSemanticDomainChange(domainId, new MultiString { ["en"] = "Food" }, "5.2"), Meta()); + var domain = await DataModel.GetLatest(domainId); + await DataModel.AddChange(ClientId, new AddSemanticDomainChange(domain!, senseId), Meta()); var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Main Dictionary"); + activities.Should().Contain(a => a.ChangeTypes.Contains("AddSemanticDomainChange") + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].Target == "5.2 Food"); } [Fact] - public async Task ProjectActivity_ChangeInfo_NamesComplexFormTypeSubject() + public async Task ProjectActivity_ChangeInfo_RemoveSemanticDomain_NamesRemovedDomain() { - await DataModel.AddChange(ClientId, new CreateComplexFormType(Guid.NewGuid(), new MultiString { ["en"] = "Compound" }), Meta()); + 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()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Compound"); + // 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_NamesMorphTypeSubject() + public async Task ProjectActivity_ChangeInfo_AddPublication_NamesAddedPublication() { - // 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 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()); var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "suffix"); + activities.Should().Contain(a => a.ChangeTypes.Contains("AddPublicationChange") + && a.ChangeInfo[0].Subject == "run" + && a.ChangeInfo[0].Target == "Main Dictionary"); } [Fact] - public async Task ProjectActivity_ChangeInfo_NamesExampleSentenceSubject() + public async Task ProjectActivity_ChangeInfo_RemovePublication_NamesRemovedPublication() { 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 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()); - // An example resolves to its parent sense's subject and its root entry (id + headword). + // 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 › to run" - && a.ChangeInfo[0].RootEntryId == entryId - && a.ChangeInfo[0].RootEntryHeadword == "run"); + && a.ChangeInfo[0].Subject == "run" + && a.ChangeInfo[0].Target == "Main Dictionary"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_AddComplexFormType_NamesAddedType() + { + 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()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + activities.Should().Contain(a => a.ChangeTypes.Contains("AddComplexFormTypeChange") + && a.ChangeInfo[0].Subject == "blackbird" + && a.ChangeInfo[0].Target == "Compound"); + } + + [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] @@ -301,89 +450,170 @@ public async Task ProjectActivity_ChangeInfo_NamesComplexFormComponentSubject_An } [Fact] - public async Task ProjectActivity_ChangeInfo_RemovedComponent_NamesEndpointsFromCreateChange() + public async Task ProjectActivity_ChangeInfo_SetComplexFormComponent_NamesComplexFormAndNewComponent() { var complexFormId = await CreateEntry("blackbird"); - var componentId = await CreateEntry("bird"); + var componentId = await CreateEntry("black"); + var newComponentId = 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()); + await DataModel.AddChange(ClientId, SetComplexFormComponentChange.NewComponent(component.Id, newComponentId), 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() + public async Task ProjectActivity_ChangeInfo_RemovedComponent_NamesEndpointsFromCreateChange() { var complexFormId = await CreateEntry("blackbird"); - var componentId = await CreateEntry("black"); - var newComponentId = await CreateEntry("bird"); + var componentId = await CreateEntry("bird"); var component = await AddComponent(complexFormId, componentId); - await DataModel.AddChange(ClientId, SetComplexFormComponentChange.NewComponent(component.Id, newComponentId), Meta()); + // 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_RemoveSemanticDomain_NamesRemovedDomain() + public async Task ProjectActivity_ChangeInfo_NamesExampleSentenceSubject() { 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 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()); - // The remove change names the sense as subject and the removed domain as target. + // An example resolves to its parent sense's subject and its root entry (id + headword); the target is a + // short snippet of the sentence text (here short enough to show whole). activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].Target == "5.2 Food"); + && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].RootEntryHeadword == "run" + && a.ChangeInfo[0].Target == "I run daily"); } [Fact] - public async Task ProjectActivity_ChangeInfo_RemovePublication_NamesRemovedPublication() + public async Task ProjectActivity_ChangeInfo_DeletedExample_StillHasSentenceSnippet() { 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 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 daily") } + }, senseId), Meta()); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(exampleId), 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 + // The deleted example is gone from the projection; its snippet is recovered from the last snapshot. + activities.Should().Contain(a => a.ChangeTypes.Contains("delete:ExampleSentence") + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].Target == "I run daily"); + } + + // --- Comments resolve to whatever their thread is attached to (subject), with the comment text as a + // truncated target snippet — the same shape as example sentences. --- + + [Fact] + public async Task ProjectActivity_ChangeInfo_CommentThread_NamesCommentedEntry() + { + var entryId = await CreateEntry("run"); + await AddCommentThread(entryId, SubjectType.Entry); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // A thread reads at the level of the thing it's on: the commented entry's headword and its root entry. + activities.Should().Contain(a => a.ChangeTypes.Contains("CreateCommentThreadChange") && a.ChangeInfo[0].Subject == "run" - && a.ChangeInfo[0].Target == "Main Dictionary"); + && a.ChangeInfo[0].RootEntryId == entryId); } [Fact] - public async Task ProjectActivity_ChangeInfo_RemoveComplexFormType_NamesRemovedType() + public async Task ProjectActivity_ChangeInfo_UserComment_NamesCommentedSense_AndTextSnippet() { - 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 entryId = await CreateEntry("run"); + var senseId = await CreateSense(entryId, gloss: "to run", order: 1.0); + var threadId = await AddCommentThread(senseId, SubjectType.Sense); + await AddComment(threadId, "looks wrong"); 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"); + // The comment resolves to its sense (subject + root entry) with the comment text as the target. + activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") + && a.ChangeInfo[0].Subject == "run › to run" + && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].Target == "looks wrong"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_UserComment_TruncatesLongText() + { + var entryId = await CreateEntry("run"); + var threadId = await AddCommentThread(entryId, SubjectType.Entry); + await AddComment(threadId, "this gloss looks wrong to me"); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // Long comment text is truncated to the example-snippet budget, backing off to the last whole word. + activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") + && a.ChangeInfo[0].Target == "this gloss looks…"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_DeletedComment_StillHasTextSnippet() + { + var entryId = await CreateEntry("run"); + var threadId = await AddCommentThread(entryId, SubjectType.Entry); + var commentId = await AddComment(threadId, "looks wrong"); + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(commentId), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + + // The deleted comment is gone from the projection; its snippet is recovered from the last snapshot. + activities.Should().Contain(a => a.ChangeTypes.Contains("delete:UserComment") + && a.ChangeInfo[0].Subject == "run" + && a.ChangeInfo[0].Target == "looks wrong"); + } + + // --- Reorder changes name the parent as the subject and (where applicable) the moved child as the target. --- + + [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. + // Pin to the reorder change: the create-sense commit resolves to the same subject/target. + activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:Sense") + && a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "to run"); } [Fact] @@ -401,11 +631,15 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentSenseAndMovedExa 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 + // Reordering an example names its parent sense as the subject, the example's root entry (id + headword), + // and a snippet of the moved example as the target. + // Pin to the reorder change: the create-example commit resolves to the same subject/root entry. + activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:ExampleSentence") + && a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run › to run" && a.ChangeInfo[0].RootEntryId == entryId - && a.ChangeInfo[0].RootEntryHeadword == "run"); + && a.ChangeInfo[0].RootEntryHeadword == "run" + && a.ChangeInfo[0].Target == "I run"); } [Fact] @@ -419,7 +653,9 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesComplexFormAndMovedCom 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 + // Pin to the reorder change: the add-component commit resolves to the same subject/target. + activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:ComplexFormComponent") + && a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "blackbird" && a.ChangeInfo[0].RootEntryId == complexFormId && a.ChangeInfo[0].Target == "bird"); diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs index 1e65bdff5b..10a95f9061 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -1,5 +1,4 @@ using LcmCrdt.Changes; -using LcmCrdt.Changes.Entries; using SIL.Harmony.Core; namespace LcmCrdt.Tests; diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs index 82da6c9afa..5b06820bc7 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs @@ -1,4 +1,5 @@ using LcmCrdt.Changes; +using LcmCrdt.Changes.Comments; using LcmCrdt.Changes.Entries; using LcmCrdt.Utils; using Microsoft.EntityFrameworkCore; @@ -47,7 +48,7 @@ protected async Task CreateSense(Guid entryId, string? gloss, double order { Id = senseId, Order = order, - Gloss = gloss is null ? new MultiString() : new MultiString { ["en"] = gloss }, + Gloss = gloss is null ? [] : new MultiString { ["en"] = gloss }, SemanticDomains = semanticDomains ?? [] }; await DataModel.AddChange(ClientId, new CreateSenseChange(sense, entryId), Meta()); @@ -63,6 +64,38 @@ protected async Task AddComponent(Guid complexFormId, Guid return component; } + protected async Task AddCommentThread(Guid subjectId, SubjectType subjectType) + { + var threadId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateCommentThreadChange(new CommentThread + { + Id = threadId, + SubjectId = subjectId, + SubjectType = subjectType, + AuthorId = "a", + AuthorName = "A", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }), Meta()); + return threadId; + } + + protected async Task AddComment(Guid threadId, string text) + { + var commentId = Guid.NewGuid(); + await DataModel.AddChange(ClientId, new CreateUserCommentChange(new UserComment + { + Id = commentId, + CommentThreadId = threadId, + Text = text, + AuthorId = "a", + AuthorName = "A", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }), Meta()); + return commentId; + } + protected async Task AddEntryCommit(CommitMetadata metadata, string? headword = null) { var entry = headword is null diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index 67f97dce34..8d1893a3b5 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -1,11 +1,11 @@ +using System.Globalization; using System.Linq.Expressions; +using System.Text.RegularExpressions; 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; @@ -24,7 +24,7 @@ namespace LcmCrdt; /// internal static class ActivityChangeInfoResolver { - public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities) + public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities, WritingSystems writingSystems) { var entryIds = new HashSet(); var senseIds = new HashSet(); @@ -35,10 +35,14 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea var publicationIds = new HashSet(); var complexFormTypeIds = new HashSet(); var morphTypeIds = new HashSet(); + var writingSystemIds = new HashSet(); + var customViewIds = new HashSet(); + var commentThreadIds = new HashSet(); + var userCommentIds = new HashSet(); foreach (var change in activities.SelectMany(a => a.Changes)) { - switch (BucketFor(change.Change.EntityType)) + switch (change.Change.EntityType.Name) { case nameof(Entry): entryIds.Add(change.EntityId); break; case nameof(Sense): senseIds.Add(change.EntityId); break; @@ -49,26 +53,48 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea case nameof(Publication): publicationIds.Add(change.EntityId); break; case nameof(ComplexFormType): complexFormTypeIds.Add(change.EntityId); break; case nameof(MorphType): morphTypeIds.Add(change.EntityId); break; + case nameof(WritingSystem): writingSystemIds.Add(change.EntityId); break; + case nameof(CustomView): customViewIds.Add(change.EntityId); break; + case nameof(CommentThread): commentThreadIds.Add(change.EntityId); break; + case nameof(UserComment): userCommentIds.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 AddSemanticDomainChange a: semanticDomainIds.Add(a.SemanticDomain.Id); break; case RemoveSemanticDomainChange r: semanticDomainIds.Add(r.SemanticDomainId); break; + case AddPublicationChange a: publicationIds.Add(a.Publication.Id); break; case RemovePublicationChange r: publicationIds.Add(r.PublicationId); break; + case AddComplexFormTypeChange a: complexFormTypeIds.Add(a.ComplexFormType.Id); 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; } } + // Comments resolve to the entry/sense/example their thread is attached to (subject) plus a snippet of the + // comment text (target), mirroring example sentences. Load comments and their threads up front so the + // commented subject's id can join the entry/sense/example batch-loads below (via the cascade further down). + var userComments = await LoadUserComments(db, userCommentIds); + await RecoverDeleted(db, userCommentIds, userComments); + foreach (var comment in userComments.Values) commentThreadIds.Add(comment.CommentThreadId); + var commentThreads = await LoadCommentThreads(db, commentThreadIds); + await RecoverDeleted(db, commentThreadIds, commentThreads); + foreach (var thread in commentThreads.Values) + { + switch (thread.SubjectType) + { + case SubjectType.Entry: entryIds.Add(thread.SubjectId); break; + case SubjectType.Sense: senseIds.Add(thread.SubjectId); break; + case SubjectType.ExampleSentence: exampleIds.Add(thread.SubjectId); 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) @@ -96,7 +122,7 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea // 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 sensesByEntry = await LoadSensesByEntry(db, [.. senses.Values.Select(s => s.EntryId)]); var entries = await LoadEntries(db, entryIds); await RecoverDeleted(db, entryIds, entries); @@ -110,16 +136,45 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea 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); + var writingSystemsById = await LoadNamed(db, writingSystemIds, + w => new WritingSystem { Id = w.Id, WsId = w.WsId, Name = w.Name, Abbreviation = w.Abbreviation, Font = w.Font, Type = w.Type }); + await RecoverDeleted(db, writingSystemIds, writingSystemsById); + var customViews = await LoadNamed(db, customViewIds, v => new CustomView { Id = v.Id, Name = v.Name }); + await RecoverDeleted(db, customViewIds, customViews); // 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 project's writing-system display order; the label helpers below pick the first non-empty + // alternative in this order rather than alphabetically by writing-system code. + var writingSystemOrder = BuildWsOrder(writingSystems); + + // The alternative to show for a multi-writing-system name: first non-empty in configured order (see BestAlternative). + string? Label(MultiString multiString) => BestAlternative(multiString.Values, writingSystemOrder, v => v?.Trim()); + + // Best non-audio headword alternative (configured order, like 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. + string? DisplayHeadword(Entry entry) + { + var headword = BestAlternative( + EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values.Where(kvp => !kvp.Key.IsAudio), + writingSystemOrder, + v => v?.Trim()); + if (string.IsNullOrEmpty(headword)) return null; + return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword; + } + + // The app shows a domain as "code name" (e.g. "5.2 Food"); the code alone is too cryptic to identify it. + string SemanticDomainLabel(SemanticDomain domain) => + Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code; + + string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry) : 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 @@ -152,10 +207,10 @@ string SenseGlossPart(Sense sense) return string.IsNullOrEmpty(glossPart) ? headword : $"{headword} › {glossPart}"; } - return activities.Select(activity => activity with + return [.. activities.Select(activity => activity with { - ChangeInfo = activity.Changes.Select(change => Build(change, Headword)).ToList(), - }).ToArray(); + ChangeInfo = [.. activity.Changes.Select(change => Build(change, Headword))], + })]; ActivityChangeInfo Build(ChangeEntity change, Func headword) { @@ -176,7 +231,7 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw 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); + return new ActivityChangeInfo(SenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId, ExampleSnippet(ex.Sentence, writingSystemOrder)); 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); @@ -188,15 +243,17 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw (string? Subject, Guid? RootEntryId) ResolveSubject(ChangeEntity change, Func headword) { var id = change.EntityId; - switch (BucketFor(change.Change.EntityType)) + switch (change.Change.EntityType.Name) { 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) + // Adding or removing a sense is a change to its parent entry's structure, so both read as + // entry-level ("headword · Added sense senseN" / "· Removed sense senseN"): the subject is the + // parent entry's headword and the sense identifier goes to Target. As inverse operations they + // stay mirror images. Field edits keep the "headword › senseLabel" subject so they read as + // sense-level (the sense itself is what's constant, a field is what changed). + if (change.Change is CreateSenseChange or DeleteChange) 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): @@ -217,6 +274,35 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw return (Label(cft.Name), null); case nameof(MorphType) when morphTypes.TryGetValue(id, out var morphType): return (Label(morphType.Name), null); + // Writing systems and custom views name themselves by their plain (non-multi-string) display name. + case nameof(WritingSystem) when writingSystemsById.TryGetValue(id, out var ws): + return (NullIfEmpty(ws.Name), null); + case nameof(CustomView) when customViews.TryGetValue(id, out var view): + return (NullIfEmpty(view.Name), null); + case nameof(CommentThread) when commentThreads.TryGetValue(id, out var thread): + return CommentSubject(thread, headword); + case nameof(UserComment) when userComments.TryGetValue(id, out var comment) + && commentThreads.TryGetValue(comment.CommentThreadId, out var commentThread): + return CommentSubject(commentThread, headword); + default: + return (null, null); + } + } + + // A comment resolves to whatever its thread is attached to — the entry headword, the "headword › gloss" + // sense label, or the example's sense label — with that entity's root entry. Mirrors the + // Entry/Sense/ExampleSentence arms above so a comment reads at the same level as an edit to its subject. + (string? Subject, Guid? RootEntryId) CommentSubject(CommentThread thread, Func headword) + { + var subjectId = thread.SubjectId; + switch (thread.SubjectType) + { + case SubjectType.Entry: + return (headword(subjectId), subjectId); + case SubjectType.Sense when senses.TryGetValue(subjectId, out var sense): + return (SenseLabel(headword(sense.EntryId), sense), sense.EntryId); + case SubjectType.ExampleSentence when examples.TryGetValue(subjectId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): + return (SenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); default: return (null, null); } @@ -226,43 +312,70 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw string? TargetLabel(ChangeEntity change) => change.Change switch { SetPartOfSpeechChange { PartOfSpeechId: { } id } when partsOfSpeech.TryGetValue(id, out var pos) => Label(pos.Name), + // Add/remove of a reference names the referenced item as the target; the two stay mirror images. + AddSemanticDomainChange a when semanticDomains.TryGetValue(a.SemanticDomain.Id, out var domain) => SemanticDomainLabel(domain), RemoveSemanticDomainChange r when semanticDomains.TryGetValue(r.SemanticDomainId, out var domain) => SemanticDomainLabel(domain), + AddPublicationChange a when publications.TryGetValue(a.Publication.Id, out var publication) => Label(publication.Name), RemovePublicationChange r when publications.TryGetValue(r.PublicationId, out var publication) => Label(publication.Name), + AddComplexFormTypeChange a when complexFormTypes.TryGetValue(a.ComplexFormType.Id, out var cft) => Label(cft.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), + AddEntryComponentChange a when entries.TryGetValue(a.ComponentEntryId, out var component) => DisplayHeadword(component), + SetComplexFormComponentChange { ComponentEntryId: { } cid } when entries.TryGetValue(cid, out var component) => DisplayHeadword(component), // 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), + && entries.TryGetValue(ep.ComponentEntryId, out var component) => DisplayHeadword(component), + // Sense create/delete pair with the ResolveSubject override above: subject is the parent entry + // headword, target names the sense ("gwa₁ · Added sense senseN" — SenseGlossPart falls back to a + // subscript when the gloss is empty, matching sense-edit summaries). + CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId, out var sense) => SenseGlossPart(sense), + // Any change to an example sentence (create/edit/delete) names the sentence itself as the target: a + // short truncated snippet, since the full text is too long and is the example's only identity. + _ when change.Change.EntityType == typeof(ExampleSentence) + && examples.TryGetValue(change.EntityId, out var ex) => ExampleSnippet(ex.Sentence, writingSystemOrder), + // Any change to a comment (create/edit/delete) names the comment text as the target: a short + // truncated snippet, like an example sentence (the subject already names what it's a comment on). + _ when change.Change.EntityType == typeof(UserComment) + && userComments.TryGetValue(change.EntityId, out var comment) => CommentSnippet(comment.Text), _ => 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. + // ARE missing (deleted objects visible on the page — rare and few), one window query picks each entity's + // newest snapshot without pulling its whole 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() + var missingIds = ids.Where(id => !loaded.ContainsKey(id)).ToHashSet(); + if (missingIds.Count == 0) return; + + var snapshots = await ( + from row in + from commit in db.Commits 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; + where missingIds.Contains(s.EntityId) + select new + { + s, + // Same keys as Harmony's Commit.DefaultOrderDescending; must stay in sync (a window's + // Over() can't take that IQueryable extension, so the ordering is spelled out here). + rn = Sql.Ext.RowNumber().Over() + .PartitionBy(s.EntityId) + .OrderByDesc(commit.HybridDateTime.DateTime) + .ThenByDesc(commit.HybridDateTime.Counter) + .ThenByDesc(commit.Id).ToValue() + } + where row.rn == 1 + select row.s + ).ToArrayAsyncLinqToDB(); + + foreach (var snapshot in snapshots) + { + if (snapshot.Entity.DbObject is T entity) loaded[snapshot.EntityId] = entity; } } @@ -309,11 +422,30 @@ private static async Task> LoadExamples(ICrdtD { 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 }) + .Select(e => new ExampleSentence { Id = e.Id, SenseId = e.SenseId, Sentence = e.Sentence }) .ToListAsyncLinqToDB(); return loaded.ToDictionary(e => e.Id); } + private static async Task> LoadUserComments(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(c => ids.Contains(c.Id)) + .Select(c => new UserComment { Id = c.Id, CommentThreadId = c.CommentThreadId, Text = c.Text }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(c => c.Id); + } + + // Only the thread's subject (what it's attached to) is needed to label a comment; its comment list isn't. + private static async Task> LoadCommentThreads(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(t => ids.Contains(t.Id)) + .Select(t => new CommentThread { Id = t.Id, SubjectId = t.SubjectId, SubjectType = t.SubjectType }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(t => t.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( @@ -363,23 +495,9 @@ private static async Task> LoadNamed(ICrdtDbContext db, H 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; - } + // A plain (non-multi-string) display name, degraded to null when blank so it matches the null-when-empty + // convention the MultiString labels use (the frontend renders a placeholder rather than an empty subject). + private static string? NullIfEmpty(string? name) => string.IsNullOrWhiteSpace(name) ? null : name; private static string Subscript(int number) { @@ -390,7 +508,78 @@ private static string Subscript(int number) return buffer[..charsWritten].ToString(); } - // 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; + private static readonly IReadOnlyDictionary EmptyWsOrder = new Dictionary(); + + // The alternative to display from a multi-writing-system value: the first non-empty one in the project's + // configured writing-system order, falling back to WS code so the pick stays deterministic for a writing + // system not in the order map (and in tests, where none is loaded). Null when every alternative is empty. + private static string? BestAlternative( + IEnumerable> alternatives, + IReadOnlyDictionary wsOrder, + Func render) + { + foreach (var kvp in alternatives + .OrderBy(kvp => wsOrder.GetValueOrDefault(kvp.Key, int.MaxValue)) + .ThenBy(kvp => kvp.Key.Code)) + { + var text = render(kvp.Value); + if (!string.IsNullOrEmpty(text)) return text; + } + return null; + } + + // Rank each writing system by the project's configured display order (GetWritingSystems already sorts by + // Order), so a label shows the alternative the editor lists first. A WsId shared by a vernacular and an + // analysis system keeps its first rank; cross-type interleaving doesn't matter since a field holds one type. + private static Dictionary BuildWsOrder(WritingSystems writingSystems) + { + var rank = new Dictionary(); + foreach (var ws in writingSystems.Vernacular.Concat(writingSystems.Analysis)) + rank.TryAdd(ws.WsId, rank.Count); + return rank; + } + + // Max grapheme clusters shown in an example-sentence snippet before it's truncated. + // Small on purpose: it acts as a name/subject for the parent entity + // Should arguably be handled in CSS, but it's nice having something subject-like to populate the Subject prop with. + public const int TextSnippetBudget = 20; + + private static readonly Regex WhitespaceRun = new(@"\s+", RegexOptions.Compiled); + + // A short one-line label for an example sentence: the best non-empty writing system's text (in configured + // order, like Label), flattened from rich text and whitespace-collapsed, then truncated to a grapheme + // budget. Null when no writing system has displayable text (mirrors the null gloss/headword degradation). + internal static string? ExampleSnippet(RichMultiString sentence, + IReadOnlyDictionary? wsOrder = null) + { + return BestAlternative(sentence, wsOrder ?? EmptyWsOrder, richString => + { + var plainText = Truncate(richString?.GetPlainText(), TextSnippetBudget); + return string.IsNullOrWhiteSpace(plainText) ? null : plainText; + }); + } + + // A short one-line label for a comment: its (plain-string) text, whitespace-collapsed and truncated to the + // same budget as an example snippet. Null when the comment has no displayable text. + private static string? CommentSnippet(string? text) => Truncate(text, TextSnippetBudget); + + // Truncate to at most `budget` grapheme clusters (never splitting a surrogate pair or combining mark), + // appending an ellipsis when text is dropped. For space-separated scripts, back off to the last space in the + // kept window so a word isn't cut mid-way; scripts without spaces (Thai, CJK, …) get a clean grapheme cut. + // Works in logical order — visual placement of the ellipsis for RTL text is the renderer's job. + private static string Truncate(string? text, int budget) + { + if (string.IsNullOrWhiteSpace(text)) return string.Empty; + var collapsed = WhitespaceRun.Replace(text, " ").Trim(); + var clusterStarts = new List(); + var enumerator = StringInfo.GetTextElementEnumerator(collapsed); + while (enumerator.MoveNext()) clusterStarts.Add(enumerator.ElementIndex); + if (clusterStarts.Count <= budget) return collapsed; + + var window = collapsed[..clusterStarts[budget]]; + var lastSpace = window.LastIndexOf(' '); + // Only honour a space past the halfway point, so a long leading token can't collapse the snippet to almost nothing. + if (lastSpace > window.Length / 2) window = window[..lastSpace]; + return window.TrimEnd() + "…"; + } } diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index 131279adac..5578cd35c4 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -9,6 +9,7 @@ using System.Text.RegularExpressions; using MiniLcm.Exceptions; using LinqToDB.Async; +using SIL.Harmony.Entities; namespace LcmCrdt; @@ -39,10 +40,9 @@ public static class ActivityFilterKeys /// /// 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. +/// is a referenced item named only by id (e.g. the assigned part of speech). Both null when unresolved. +/// is the entry the change ultimately belongs to (the entry itself, or the owner of a sense/example); +/// is 's headword, for grouping a commit's changes under one entry. /// public record ActivityChangeInfo(string? Subject, Guid? RootEntryId, string? Target = null, string? RootEntryHeadword = null); @@ -53,7 +53,7 @@ public record ProjectActivity( CommitMetadata Metadata) { public string ChangeName => HistoryService.ChangesNameHelper(Changes); - public string[] ChangeTypes { get; } = Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct().ToArray(); + public string[] ChangeTypes { get; } = [.. Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct()]; /// Resolved display info per change, parallel to by index. Set during enrichment. public IReadOnlyList ChangeInfo { get; init; } = []; } @@ -118,7 +118,7 @@ public async Task ListActivityAuthors() }) .Select(g => new ActivityAuthor(g.Key.AuthorId, g.Key.AuthorName, g.Count())) .ToListAsyncLinqToDB(); - return authors.OrderBy(a => a.AuthorName ?? "").ThenBy(a => a.AuthorId ?? "").ToArray(); + return [.. authors.OrderBy(a => a.AuthorName ?? "").ThenBy(a => a.AuthorId ?? "")]; } public async Task ListActivityChangeTypes() @@ -159,7 +159,8 @@ from commit in commits.Skip(skip).Take(take) commit.ChangeEntities.ToList(), commit.Metadata); var activities = await queryable.ToLinqToDB().ToArrayAsyncLinqToDB(); - return await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities); + var writingSystems = await miniLcmApi.GetWritingSystems(); + return await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities, writingSystems); } private static IQueryable ApplyActivityFilters(IQueryable commits, ActivityQuery query) @@ -241,7 +242,7 @@ private static IQueryable ApplyActivitySort(IQueryable commits, /// public static string GetChangeTypeKeyFromType(Type changeType) { - var typeNameProp = changeType.GetProperty("TypeName", + var typeNameProp = changeType.GetProperty(nameof(IPolyType.TypeName), System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.FlattenHierarchy); if (typeNameProp?.GetValue(null) is string name) return name; @@ -318,32 +319,26 @@ public async Task LoadChangeContext(Guid commitId, int changeInde .FirstOrDefaultAsync() ?? throw new InvalidOperationException($"Change {changeIndex} not found in commit {commitId}"); - // 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) + // These three branches are independent read-only lookups that each open their own + // DbContext (via the repository factories), so run them concurrently. + var snapshotTask = ResolveSnapshot(async () => await dataModel.GetAtCommit(commitId, change.EntityId)); + var previousSnapshotTask = ResolveSnapshot(() => dataModel.GetBeforeCommit(commitId, change.EntityId)); + var affectedEntriesTask = GetAffectedEntryIds(change) .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) - .ToArrayAsync(); + .ToArrayAsync() + .AsTask(); - return new ChangeContext(change, snapshot, previousSnapshot, affectedEntries); + await Task.WhenAll(snapshotTask, previousSnapshotTask, affectedEntriesTask); + + return new ChangeContext(change, await snapshotTask, await previousSnapshotTask, await affectedEntriesTask); } - /// 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) + // Some entity types like RemoteResource don't implement IObjectWithId, so cast safely. + private async Task ResolveSnapshot(Func> fetch) { - 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; + var snapshot = await fetch() as IObjectWithId; + await ResolveSensePartOfSpeech(snapshot); + return snapshot; } // Older sense snapshots didn't store the part-of-speech object, only its id; resolve it so previews can show a label. diff --git a/frontend/viewer/src/lib/activity/utils.ts b/frontend/viewer/src/lib/activity/utils.ts index d27f8fc5fc..446eb496ec 100644 --- a/frontend/viewer/src/lib/activity/utils.ts +++ b/frontend/viewer/src/lib/activity/utils.ts @@ -1,4 +1,5 @@ import {ActivitySort, type IActivityAuthor, type IActivityQuery, type IProjectActivity} from '$lib/dotnet-types'; +import type {ChangeType} from '$lib/dotnet-types/generated-types/LcmCrdt/ChangeType'; import {gt} from 'svelte-i18n-lingui'; export const ALL_AUTHORS = '__all__'; @@ -48,7 +49,7 @@ export function resolveFilterKeys(selected: MultiFilterSelection, allKeys: strin export function toServerQuery(filters: ActivityFilters): IActivityQuery { return { authorFilterKeys: filters.authorFilterKeys === 'all' ? undefined : filters.authorFilterKeys, - changeTypeKeys: filters.changeTypeFilterKeys === 'all' ? undefined : filters.changeTypeFilterKeys, + changeTypeKeys: filters.changeTypeFilterKeys === 'all' ? undefined : filters.changeTypeFilterKeys as ChangeType[], sort: filters.sort, }; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts similarity index 98% rename from frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts rename to frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts index dcd13d42da..bd8dd453d8 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts @@ -68,6 +68,7 @@ export type ChangeType = | 'jsonPatch:SemanticDomain' | 'jsonPatch:Sense' | 'jsonPatch:WritingSystem' + | 'set:remote-resource-metadata' | 'uploaded:RemoteResource'; export const knownChangeTypes = [ @@ -135,6 +136,7 @@ export const knownChangeTypes = [ 'jsonPatch:SemanticDomain', 'jsonPatch:Sense', 'jsonPatch:WritingSystem', + 'set:remote-resource-metadata', 'uploaded:RemoteResource', ] as const satisfies readonly ChangeType[]; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts index 85e76c74ff..e63464081e 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts @@ -3,9 +3,11 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {ChangeType} from './ChangeType'; + export interface IActivityChangeType { - key: string; + key: ChangeType; label: string; commitCount: number; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts index 686a24fa44..49a0553b14 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts @@ -3,12 +3,13 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {ChangeType} from './ChangeType'; import type {ActivitySort} from './ActivitySort'; export interface IActivityQuery { authorFilterKeys?: string[]; - changeTypeKeys?: string[]; + changeTypeKeys?: ChangeType[]; sort: ActivitySort; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts index 8534c0a289..426917d253 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts @@ -5,6 +5,7 @@ import type {IChangeEntity} from '../SIL/Harmony/Core/IChangeEntity'; import type {ICommitMetadata} from '../SIL/Harmony/Core/ICommitMetadata'; +import type {ChangeType} from './ChangeType'; import type {IActivityChangeInfo} from './IActivityChangeInfo'; export interface IProjectActivity @@ -14,7 +15,7 @@ export interface IProjectActivity changes: IChangeEntity[]; metadata: ICommitMetadata; changeName: string; - changeTypes: string[]; + changeTypes: ChangeType[]; changeInfo: IActivityChangeInfo[]; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts index 713b3e6c39..99032a877b 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts @@ -9,4 +9,4 @@ export * from './IActivityChangeType' export * from './IActivityChangeInfo' export * from './IActivityQuery' export * from './ActivitySort' -export * from './ChangeTypes' +export * from './ChangeType' From 4aa87685286d3b796120a16028969dbd3bcf127f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 21 Jul 2026 16:29:44 +0200 Subject: [PATCH 03/11] Tidy up and optimize --- .../LcmCrdt/ActivityChangeInfoResolver.cs | 42 ++++++++++--------- backend/FwLite/LcmCrdt/HistoryService.cs | 9 +++- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index 8d1893a3b5..8cdfc3ebb8 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -80,11 +80,9 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea // Comments resolve to the entry/sense/example their thread is attached to (subject) plus a snippet of the // comment text (target), mirroring example sentences. Load comments and their threads up front so the // commented subject's id can join the entry/sense/example batch-loads below (via the cascade further down). - var userComments = await LoadUserComments(db, userCommentIds); - await RecoverDeleted(db, userCommentIds, userComments); + var userComments = await LoadWithRecovery(db, userCommentIds, LoadUserComments); foreach (var comment in userComments.Values) commentThreadIds.Add(comment.CommentThreadId); - var commentThreads = await LoadCommentThreads(db, commentThreadIds); - await RecoverDeleted(db, commentThreadIds, commentThreads); + var commentThreads = await LoadWithRecovery(db, commentThreadIds, LoadCommentThreads); foreach (var thread in commentThreads.Values) { switch (thread.SubjectType) @@ -113,34 +111,24 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea entryIds.Add(complexFormEntryId); entryIds.Add(componentEntryId); } - var examples = await LoadExamples(db, exampleIds); - await RecoverDeleted(db, exampleIds, examples); + var examples = await LoadWithRecovery(db, exampleIds, LoadExamples); foreach (var example in examples.Values) senseIds.Add(example.SenseId); - var senses = await LoadSenses(db, senseIds); - await RecoverDeleted(db, senseIds, senses); + var senses = await LoadWithRecovery(db, senseIds, LoadSenses); 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)]); - var entries = await LoadEntries(db, entryIds); - await RecoverDeleted(db, entryIds, entries); + var entries = await LoadWithRecovery(db, entryIds, LoadEntries); 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 semanticDomains = await LoadWithRecovery(db, semanticDomainIds, LoadSemanticDomains); 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); var writingSystemsById = await LoadNamed(db, writingSystemIds, w => new WritingSystem { Id = w.Id, WsId = w.WsId, Name = w.Name, Abbreviation = w.Abbreviation, Font = w.Font, Type = w.Type }); - await RecoverDeleted(db, writingSystemIds, writingSystemsById); var customViews = await LoadNamed(db, customViewIds, v => new CustomView { Id = v.Id, Name = v.Name }); - await RecoverDeleted(db, customViewIds, customViews); // 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. @@ -342,6 +330,19 @@ CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId }; } + // A projected load paired with its deleted-id recovery — the two always go together for a label load, so a + // deleted object can still be named. (LoadNamed recovers internally; the loads that recover a different way, + // like ComplexFormComponent via its create change, don't use this.) + private static async Task> LoadWithRecovery(ICrdtDbContext db, + HashSet ids, + Func, Task>> load) + where T : class, IObjectWithId + { + var loaded = await load(db, ids); + await RecoverDeleted(db, ids, loaded); + return loaded; + } + // 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 @@ -487,12 +488,15 @@ private static async Task> LoadSemanticDomains( } // Vocab objects the resolver only labels by name (part of speech, publication, complex-form type, morph type). + // Recovers deleted ids like the per-type loads, so a removed vocab object can still be named. 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); + var result = loaded.ToDictionary(o => o.Id); + await RecoverDeleted(db, ids, result); + return result; } // A plain (non-multi-string) display name, degraded to null when blank so it matches the null-when-empty diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index 5578cd35c4..e94703e802 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -158,8 +158,13 @@ from commit in commits.Skip(skip).Take(take) commit.HybridDateTime.DateTime, commit.ChangeEntities.ToList(), commit.Metadata); - var activities = await queryable.ToLinqToDB().ToArrayAsyncLinqToDB(); - var writingSystems = await miniLcmApi.GetWritingSystems(); + + // Independent reads on separate contexts (GetWritingSystems opens its own repo), so overlap them. + var activitiesTask = queryable.ToLinqToDB().ToArrayAsyncLinqToDB(); + var writingSystemsTask = miniLcmApi.GetWritingSystems(); + await Task.WhenAll(activitiesTask, writingSystemsTask); + var activities = await activitiesTask; + var writingSystems = await writingSystemsTask; return await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities, writingSystems); } From e1fd2146dc7f6d7d2c0a13e573c5d164bf8bcc16 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 12:57:08 +0200 Subject: [PATCH 04/11] Standardize terminology --- .../TypeGen/ChangeTypesCodeGenerator.cs | 2 +- .../HistoryServiceActivitySubjectTests.cs | 79 ++++------ .../LcmCrdt/ActivityChangeInfoResolver.cs | 141 +++++++----------- backend/FwLite/LcmCrdt/HistoryService.cs | 8 +- .../FwLite/MiniLcm/Models/ComplexFormType.cs | 2 +- backend/FwLite/MiniLcm/Models/MorphType.cs | 2 +- backend/FwLite/MiniLcm/Models/PartOfSpeech.cs | 2 +- .../FwLite/MiniLcm/Models/SemanticDomain.cs | 2 +- .../generated-types/LcmCrdt/ChangeType.ts | 2 - .../LcmCrdt/IActivityChangeInfo.ts | 4 +- 10 files changed, 100 insertions(+), 144 deletions(-) diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs index 258f4e1781..9b3dc10380 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs @@ -8,7 +8,7 @@ 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 +/// discriminator (). Attached to the ChangeType /// marker; suppresses the marker's own output. /// public class ChangeTypesCodeGenerator : ClassCodeGenerator diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs index c1726e6fd7..3747c77d9e 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs @@ -105,12 +105,12 @@ public async Task ProjectActivity_ChangeInfo_EmptyEntryHasNullSubject() 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. + // No displayable headword: the subject and owning-entry headword are null (the frontend renders its + // placeholder, never "(Unknown)"), but the change still resolves to its owning entry. activities.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].OwningEntryId == entryId && a.ChangeInfo[0].Subject == null - && a.ChangeInfo[0].RootEntryHeadword == null); + && a.ChangeInfo[0].OwningEntryHeadword == null); } [Fact] @@ -128,11 +128,12 @@ public async Task ProjectActivity_ChangeInfo_UnlabellableSenseHasNullSubject() // 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].OwningEntryId == entryId && a.ChangeInfo[0].Subject == null); } - // --- Vocab objects name themselves by their display name, and recover that label after deletion. --- + // --- Possibilities, writing systems, and custom views name themselves by their display name, and recover + // that label after deletion. --- [Fact] public async Task ProjectActivity_ChangeInfo_NamesPartOfSpeechSubject_EvenAfterDelete() @@ -141,11 +142,11 @@ public async Task ProjectActivity_ChangeInfo_NamesPartOfSpeechSubject_EvenAfterD await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(id, new MultiString { ["en"] = "Verb" }), Meta()); var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // A vocab object names itself by its display name and has no root entry (so no root-entry headword either). + // A possibility names itself by its display name and has no owning entry (so no owning-entry headword either). live.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Verb" - && a.ChangeInfo[0].RootEntryId == null - && a.ChangeInfo[0].RootEntryHeadword == null); + && a.ChangeInfo[0].OwningEntryId == null + && a.ChangeInfo[0].OwningEntryHeadword == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); @@ -165,7 +166,7 @@ public async Task ProjectActivity_ChangeInfo_NamesSemanticDomainSubject_EvenAfte // A domain shows as "code name". live.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "5.2 Food" - && a.ChangeInfo[0].RootEntryId == null); + && a.ChangeInfo[0].OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); @@ -183,7 +184,7 @@ public async Task ProjectActivity_ChangeInfo_NamesPublicationSubject_EvenAfterDe var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); live.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Main Dictionary" - && a.ChangeInfo[0].RootEntryId == null); + && a.ChangeInfo[0].OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); @@ -201,7 +202,7 @@ public async Task ProjectActivity_ChangeInfo_NamesComplexFormTypeSubject_EvenAft var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); live.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "Compound" - && a.ChangeInfo[0].RootEntryId == null); + && a.ChangeInfo[0].OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); @@ -225,10 +226,10 @@ public async Task ProjectActivity_ChangeInfo_NamesWritingSystemSubject_EvenAfter }, id, 0), Meta()); var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // A writing system names itself by its plain display name and has no root entry. + // A writing system names itself by its plain display name and has no owning entry. live.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "French" - && a.ChangeInfo[0].RootEntryId == null); + && a.ChangeInfo[0].OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); @@ -252,7 +253,7 @@ public async Task ProjectActivity_ChangeInfo_NamesCustomViewSubject_EvenAfterDel var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); live.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "My View" - && a.ChangeInfo[0].RootEntryId == null); + && a.ChangeInfo[0].OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); @@ -288,7 +289,7 @@ public async Task ProjectActivity_ChangeInfo_DeletedEntry_StillHasHeadword() // 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"); + && a.ChangeInfo[0].OwningEntryHeadword == "run"); } [Fact] @@ -445,33 +446,17 @@ public async Task ProjectActivity_ChangeInfo_NamesComplexFormComponentSubject_An // 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].OwningEntryId == complexFormId && 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_RemovedComponent_NamesEndpointsFromCreateChange() + public async Task ProjectActivity_ChangeInfo_RemovedComponent_StillNamesEndpoints() { 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. + // Delete the link: it leaves the projection, so its endpoints must be recovered from its last snapshot. await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(component.Id), Meta()); var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); @@ -495,12 +480,12 @@ public async Task ProjectActivity_ChangeInfo_NamesExampleSentenceSubject() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // An example resolves to its parent sense's subject and its root entry (id + headword); the target is a + // An example resolves to its parent sense's subject and its owning entry (id + headword); the target is a // short snippet of the sentence text (here short enough to show whole). 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" + && a.ChangeInfo[0].OwningEntryId == entryId + && a.ChangeInfo[0].OwningEntryHeadword == "run" && a.ChangeInfo[0].Target == "I run daily"); } @@ -536,10 +521,10 @@ public async Task ProjectActivity_ChangeInfo_CommentThread_NamesCommentedEntry() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // A thread reads at the level of the thing it's on: the commented entry's headword and its root entry. + // A thread reads at the level of the thing it's on: the commented entry's headword and its owning entry. activities.Should().Contain(a => a.ChangeTypes.Contains("CreateCommentThreadChange") && a.ChangeInfo[0].Subject == "run" - && a.ChangeInfo[0].RootEntryId == entryId); + && a.ChangeInfo[0].OwningEntryId == entryId); } [Fact] @@ -552,10 +537,10 @@ public async Task ProjectActivity_ChangeInfo_UserComment_NamesCommentedSense_And var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // The comment resolves to its sense (subject + root entry) with the comment text as the target. + // The comment resolves to its sense (subject + owning entry) with the comment text as the target. activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].RootEntryId == entryId + && a.ChangeInfo[0].OwningEntryId == entryId && a.ChangeInfo[0].Target == "looks wrong"); } @@ -631,14 +616,14 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentSenseAndMovedExa var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // Reordering an example names its parent sense as the subject, the example's root entry (id + headword), + // Reordering an example names its parent sense as the subject, the example's owning entry (id + headword), // and a snippet of the moved example as the target. - // Pin to the reorder change: the create-example commit resolves to the same subject/root entry. + // Pin to the reorder change: the create-example commit resolves to the same subject/owning entry. activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:ExampleSentence") && a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].RootEntryId == entryId - && a.ChangeInfo[0].RootEntryHeadword == "run" + && a.ChangeInfo[0].OwningEntryId == entryId + && a.ChangeInfo[0].OwningEntryHeadword == "run" && a.ChangeInfo[0].Target == "I run"); } @@ -657,7 +642,7 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesComplexFormAndMovedCom activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:ComplexFormComponent") && a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "blackbird" - && a.ChangeInfo[0].RootEntryId == complexFormId + && a.ChangeInfo[0].OwningEntryId == complexFormId && a.ChangeInfo[0].Target == "bird"); } } diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index 8cdfc3ebb8..7ff51dae0f 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -2,6 +2,7 @@ using System.Linq.Expressions; using System.Text.RegularExpressions; using LcmCrdt.Changes; +using LcmCrdt.Changes.Comments; using LcmCrdt.Changes.Entries; using LcmCrdt.Data; using LinqToDB; @@ -13,14 +14,20 @@ 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. +/// Batch-resolves context for each change in a page of activity, so summaries +/// can name the entry/sense/possibility a change is about without a per-row lookup. +/// +/// Names (headwords, glosses, possibility names): +/// - Use CURRENT data (from the projected tables) +/// - Deleted objects are recovered from their latest snapshot. +/// e.g. CreateEntryChange shows the entry's current headword, not the headword at creation time. +/// We don't use names from renames/patch changes, since only some changes carry enough data to compute a label +/// and mixed historical/current rows would be worse than uniform drift. +/// +/// Quotes (longer texts e.g. example sentences, comment snippets): +/// - Use the text from the change payload, not the current text. +/// +/// Degradable: leaves null for types it doesn't resolve. /// internal static class ActivityChangeInfoResolver { @@ -73,7 +80,6 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea entryIds.Add(a.ComponentEntryId); entryIds.Add(a.ComplexFormEntryId); break; - case SetComplexFormComponentChange { ComponentEntryId: { } cid }: entryIds.Add(cid); break; } } @@ -93,24 +99,14 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea } } - // 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); + // Walk up to the owning entry: component → entry, example → sense → entry. Load both ends of a component link so we can name either. + var components = await LoadWithRecovery(db, componentIds, LoadComponents); 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 LoadWithRecovery(db, exampleIds, LoadExamples); foreach (var example in examples.Values) senseIds.Add(example.SenseId); var senses = await LoadWithRecovery(db, senseIds, LoadSenses); @@ -164,14 +160,9 @@ string SemanticDomainLabel(SemanticDomain domain) => string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry) : 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) + // Gloss + sense number if >1 sense (Mirrors FieldWorks) + // gloss-less sense renders as "({number})" — parenthesized so it can't read as a numeric gloss; + string SenseLabel(Sense sense) { var siblings = sensesByEntry.GetValueOrDefault(sense.EntryId) ?? []; var index = siblings.FindIndex(s => s.Id == sense.Id); @@ -188,11 +179,11 @@ string SenseGlossPart(Sense sense) // "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) + string? QualifiedSenseLabel(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}"; + var senseLabel = SenseLabel(sense); + if (headword is null) return string.IsNullOrEmpty(senseLabel) ? null : senseLabel; + return string.IsNullOrEmpty(senseLabel) ? headword : $"{headword} › {senseLabel}"; } return [.. activities.Select(activity => activity with @@ -205,10 +196,10 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw var info = ResolveReorder(change); if (info is null) { - var (subject, rootEntryId) = ResolveSubject(change, headword); - info = new ActivityChangeInfo(subject, rootEntryId, TargetLabel(change)); + var (subject, owningEntryId) = ResolveSubject(change, headword); + info = new ActivityChangeInfo(subject, owningEntryId, TargetLabel(change)); } - return info.RootEntryId is { } rootId ? info with { RootEntryHeadword = headword(rootId) } : info; + return info.OwningEntryId is { } owningId ? info with { OwningEntryHeadword = headword(owningId) } : info; } // A reorder reads best as " · Reordered ": the subject is the parent whose list changed, the target is the moved item. @@ -217,9 +208,9 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw switch (change.Change) { case Changes.SetOrderChange when senses.TryGetValue(change.EntityId, out var sense): - return new ActivityChangeInfo(Headword(sense.EntryId), sense.EntryId, SenseGlossPart(sense)); + return new ActivityChangeInfo(Headword(sense.EntryId), sense.EntryId, SenseLabel(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, ExampleSnippet(ex.Sentence, writingSystemOrder)); + return new ActivityChangeInfo(QualifiedSenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId, ExampleSnippet(ex.Sentence, writingSystemOrder)); 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); @@ -228,7 +219,7 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw } } - (string? Subject, Guid? RootEntryId) ResolveSubject(ChangeEntity change, Func headword) + (string? Subject, Guid? OwningEntryId) ResolveSubject(ChangeEntity change, Func headword) { var id = change.EntityId; switch (change.Change.EntityType.Name) @@ -238,20 +229,16 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw case nameof(Sense) when senses.TryGetValue(id, out var sense): // Adding or removing a sense is a change to its parent entry's structure, so both read as // entry-level ("headword · Added sense senseN" / "· Removed sense senseN"): the subject is the - // parent entry's headword and the sense identifier goes to Target. As inverse operations they - // stay mirror images. Field edits keep the "headword › senseLabel" subject so they read as + // parent entry's headword and the sense identifier goes to Target. + // Field edits keep the "headword › senseLabel" subject so they read as // sense-level (the sense itself is what's constant, a field is what changed). if (change.Change is CreateSenseChange or DeleteChange) return (headword(sense.EntryId), sense.EntryId); - return (SenseLabel(headword(sense.EntryId), sense), sense.EntryId); + return (QualifiedSenseLabel(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); + return (QualifiedSenseLabel(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): @@ -262,7 +249,6 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw return (Label(cft.Name), null); case nameof(MorphType) when morphTypes.TryGetValue(id, out var morphType): return (Label(morphType.Name), null); - // Writing systems and custom views name themselves by their plain (non-multi-string) display name. case nameof(WritingSystem) when writingSystemsById.TryGetValue(id, out var ws): return (NullIfEmpty(ws.Name), null); case nameof(CustomView) when customViews.TryGetValue(id, out var view): @@ -270,7 +256,7 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw case nameof(CommentThread) when commentThreads.TryGetValue(id, out var thread): return CommentSubject(thread, headword); case nameof(UserComment) when userComments.TryGetValue(id, out var comment) - && commentThreads.TryGetValue(comment.CommentThreadId, out var commentThread): + && commentThreads.TryGetValue(comment.CommentThreadId, out var commentThread): return CommentSubject(commentThread, headword); default: return (null, null); @@ -278,9 +264,9 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw } // A comment resolves to whatever its thread is attached to — the entry headword, the "headword › gloss" - // sense label, or the example's sense label — with that entity's root entry. Mirrors the + // sense label, or the example's sense label — with that entity's owning entry. Mirrors the // Entry/Sense/ExampleSentence arms above so a comment reads at the same level as an edit to its subject. - (string? Subject, Guid? RootEntryId) CommentSubject(CommentThread thread, Func headword) + (string? Subject, Guid? OwningEntryId) CommentSubject(CommentThread thread, Func headword) { var subjectId = thread.SubjectId; switch (thread.SubjectType) @@ -288,9 +274,9 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw case SubjectType.Entry: return (headword(subjectId), subjectId); case SubjectType.Sense when senses.TryGetValue(subjectId, out var sense): - return (SenseLabel(headword(sense.EntryId), sense), sense.EntryId); + return (QualifiedSenseLabel(headword(sense.EntryId), sense), sense.EntryId); case SubjectType.ExampleSentence when examples.TryGetValue(subjectId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): - return (SenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); + return (QualifiedSenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); default: return (null, null); } @@ -300,7 +286,6 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw string? TargetLabel(ChangeEntity change) => change.Change switch { SetPartOfSpeechChange { PartOfSpeechId: { } id } when partsOfSpeech.TryGetValue(id, out var pos) => Label(pos.Name), - // Add/remove of a reference names the referenced item as the target; the two stay mirror images. AddSemanticDomainChange a when semanticDomains.TryGetValue(a.SemanticDomain.Id, out var domain) => SemanticDomainLabel(domain), RemoveSemanticDomainChange r when semanticDomains.TryGetValue(r.SemanticDomainId, out var domain) => SemanticDomainLabel(domain), AddPublicationChange a when publications.TryGetValue(a.Publication.Id, out var publication) => Label(publication.Name), @@ -309,20 +294,26 @@ AddComplexFormTypeChange a when complexFormTypes.TryGetValue(a.ComplexFormType.I 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), - SetComplexFormComponentChange { ComponentEntryId: { } cid } when entries.TryGetValue(cid, out var component) => DisplayHeadword(component), - // A deleted component link (e.g. "Removed component"): name the component from the recovered endpoints. + // A deleted component link (e.g. "Removed component"): name the component from the recovered link. _ when change.Change.EntityType == typeof(ComplexFormComponent) - && deletedComponentEndpoints.TryGetValue(change.EntityId, out var ep) - && entries.TryGetValue(ep.ComponentEntryId, out var component) => DisplayHeadword(component), + && components.TryGetValue(change.EntityId, out var link) + && entries.TryGetValue(link.ComponentEntryId, out var component) => DisplayHeadword(component), // Sense create/delete pair with the ResolveSubject override above: subject is the parent entry - // headword, target names the sense ("gwa₁ · Added sense senseN" — SenseGlossPart falls back to a + // headword, target names the sense ("gwa₁ · Added sense senseN" — SenseLabel falls back to a // subscript when the gloss is empty, matching sense-edit summaries). - CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId, out var sense) => SenseGlossPart(sense), - // Any change to an example sentence (create/edit/delete) names the sentence itself as the target: a - // short truncated snippet, since the full text is too long and is the example's only identity. + CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId, out var sense) => SenseLabel(sense), + // A create's snippet quotes the text as it was WRITTEN, from the change's own payload: unlike a + // headword or gloss (identifiers that should track current state), a snippet reads as a quote of + // the created content, so a later edit must not rewrite it. Null for an example created blank — + // correct, nothing was written. These arms must precede the entity-type arms below. + CreateExampleSentenceChange c => c.Sentence is { } s ? ExampleSnippet(s, writingSystemOrder) : null, + CreateUserCommentChange c => CommentSnippet(c.Text), + // Any other change to an example sentence (edit/delete) names the sentence itself as the target: a + // short truncated snippet of the current text, since the full text is too long and is the example's + // only identity. _ when change.Change.EntityType == typeof(ExampleSentence) && examples.TryGetValue(change.EntityId, out var ex) => ExampleSnippet(ex.Sentence, writingSystemOrder), - // Any change to a comment (create/edit/delete) names the comment text as the target: a short + // Any other change to a comment (edit/delete) names the current comment text as the target: a short // truncated snippet, like an example sentence (the subject already names what it's a comment on). _ when change.Change.EntityType == typeof(UserComment) && userComments.TryGetValue(change.EntityId, out var comment) => CommentSnippet(comment.Text), @@ -331,8 +322,7 @@ CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId } // A projected load paired with its deleted-id recovery — the two always go together for a label load, so a - // deleted object can still be named. (LoadNamed recovers internally; the loads that recover a different way, - // like ComplexFormComponent via its create change, don't use this.) + // deleted object can still be named. (LoadNamed recovers internally.) private static async Task> LoadWithRecovery(ICrdtDbContext db, HashSet ids, Func, Task>> load) @@ -447,23 +437,6 @@ private static async Task> LoadCommentThreads(IC return loaded.ToDictionary(t => t.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 []; @@ -487,8 +460,8 @@ private static async Task> LoadSemanticDomains( return loaded.ToDictionary(d => d.Id); } - // Vocab objects the resolver only labels by name (part of speech, publication, complex-form type, morph type). - // Recovers deleted ids like the per-type loads, so a removed vocab object can still be named. + // Objects the resolver labels only by name (possibilities like parts of speech, plus writing systems and + // custom views). Recovers deleted ids like the per-type loads, so a removed item can still be named. private static async Task> LoadNamed(ICrdtDbContext db, HashSet ids, Expression> project) where T : class, IObjectWithId { diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index e94703e802..f89a334076 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -39,12 +39,12 @@ public static class ActivityFilterKeys /// /// 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 the entity the change is on (entry headword, "headword › gloss" for a sense, or a possibility's name); /// is a referenced item named only by id (e.g. the assigned part of speech). Both null when unresolved. -/// is the entry the change ultimately belongs to (the entry itself, or the owner of a sense/example); -/// is 's headword, for grouping a commit's changes under one entry. +/// is the entry the change ultimately belongs to (the entry itself, or the owner of a sense/example); +/// is 's headword, for grouping a commit's changes under one entry. /// -public record ActivityChangeInfo(string? Subject, Guid? RootEntryId, string? Target = null, string? RootEntryHeadword = null); +public record ActivityChangeInfo(string? Subject, Guid? OwningEntryId, string? Target = null, string? OwningEntryHeadword = null); public record ProjectActivity( Guid CommitId, diff --git a/backend/FwLite/MiniLcm/Models/ComplexFormType.cs b/backend/FwLite/MiniLcm/Models/ComplexFormType.cs index df53a9b72c..3dbb3c5817 100644 --- a/backend/FwLite/MiniLcm/Models/ComplexFormType.cs +++ b/backend/FwLite/MiniLcm/Models/ComplexFormType.cs @@ -1,7 +1,7 @@ namespace MiniLcm.Models; //todo support an order for the complex form types, might be here, or on the entry -public record ComplexFormType : IObjectWithId +public record ComplexFormType : IPossibility, IObjectWithId { public virtual Guid Id { get; set; } public virtual required MultiString Name { get; set; } diff --git a/backend/FwLite/MiniLcm/Models/MorphType.cs b/backend/FwLite/MiniLcm/Models/MorphType.cs index 281e62c96e..0ce8509332 100644 --- a/backend/FwLite/MiniLcm/Models/MorphType.cs +++ b/backend/FwLite/MiniLcm/Models/MorphType.cs @@ -27,7 +27,7 @@ public enum MorphTypeKind DiscontiguousPhrase, } -public class MorphType : IObjectWithId +public class MorphType : IPossibility, IObjectWithId { public virtual Guid Id { get; set; } public virtual required MorphTypeKind Kind { get; set; } diff --git a/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs b/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs index 1390973576..92b77fd4b9 100644 --- a/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs +++ b/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs @@ -1,6 +1,6 @@ namespace MiniLcm.Models; -public class PartOfSpeech : IObjectWithId +public class PartOfSpeech : IPossibility, IObjectWithId { public Guid Id { get; set; } public virtual MultiString Name { get; set; } = new(); diff --git a/backend/FwLite/MiniLcm/Models/SemanticDomain.cs b/backend/FwLite/MiniLcm/Models/SemanticDomain.cs index 15437b9bb1..f300d8c0da 100644 --- a/backend/FwLite/MiniLcm/Models/SemanticDomain.cs +++ b/backend/FwLite/MiniLcm/Models/SemanticDomain.cs @@ -1,6 +1,6 @@ namespace MiniLcm.Models; -public class SemanticDomain : IObjectWithId +public class SemanticDomain : IPossibility, IObjectWithId { public virtual Guid Id { get; set; } public virtual MultiString Name { get; set; } = new(); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts index bd8dd453d8..a842b06404 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts @@ -34,7 +34,6 @@ export type ChangeType = | 'ReplacePublicationChange' | 'ReplaceSemanticDomainChange' | 'SetCommentThreadStatusChange' - | 'SetComplexFormComponentChange' | 'SetFirstTranslationIdChange' | 'SetMainPublicationChange' | 'SetOrderChange:ComplexFormComponent' @@ -102,7 +101,6 @@ export const knownChangeTypes = [ 'ReplacePublicationChange', 'ReplaceSemanticDomainChange', 'SetCommentThreadStatusChange', - 'SetComplexFormComponentChange', 'SetFirstTranslationIdChange', 'SetMainPublicationChange', 'SetOrderChange:ComplexFormComponent', diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts index afd3ccc7d3..420182409d 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts @@ -6,8 +6,8 @@ export interface IActivityChangeInfo { subject?: string; - rootEntryId?: string; + owningEntryId?: string; target?: string; - rootEntryHeadword?: string; + owningEntryHeadword?: string; } /* eslint-enable */ From 57aa90308b9ceb33194a0dadb9036a53f08fe217 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 12:57:51 +0200 Subject: [PATCH 05/11] Solidify historical data policy --- .../HistoryServiceActivityLabelPolicyTests.cs | 68 +++++++++++++++++++ .../HistoryServiceActivitySubjectTests.cs | 4 +- .../LcmCrdt/ActivityChangeInfoResolver.cs | 34 +++++----- 3 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs new file mode 100644 index 0000000000..dbc7c9c772 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs @@ -0,0 +1,68 @@ +using LcmCrdt.Changes; +using LcmCrdt.Changes.Comments; +using SystemTextJsonPatch; + +namespace LcmCrdt.Tests; + +/// +/// Pins down the label policy described on with one test per behavior: +/// identifying labels track the newest known state (so they drift as the data changes), comment snippets quote +/// the written text (so they never drift), plus the sense-numbering quirk the policy causes. +/// +public class HistoryServiceActivityLabelPolicyTests : HistoryServiceActivityTestsBase +{ + [Fact] + public async Task ProjectActivity_ChangeInfo_IdentifyingLabels_TrackCurrentState_EvenAfterDelete() + { + var entryId = await CreateEntry("run"); + var rename = new JsonPatchDocument(); + rename.Replace(e => e.LexemeForm["en"], "jog"); + await DataModel.AddChange(ClientId, new JsonPatchChange(entryId, rename), Meta()); + + var renamed = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // The change that created the entry as "run" now names it by its current headword. + renamed.Should().Contain(a => a.ChangeTypes.Contains("CreateEntryChange") && a.ChangeInfo[0].Subject == "jog"); + + await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(entryId), Meta()); + + var deleted = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // Deletion doesn't undo the drift: the label recovered from the last snapshot is the renamed one. + deleted.Should().Contain(a => a.ChangeTypes.Contains("CreateEntryChange") && a.ChangeInfo[0].Subject == "jog"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_CommentSnippets_QuoteWrittenText_NeverDrift() + { + var entryId = await CreateEntry("run"); + var threadId = await AddCommentThread(entryId, SubjectType.Entry); + var commentId = await AddComment(threadId, "original words"); + await DataModel.AddChange(ClientId, new EditUserCommentChange(commentId, "second words", DateTimeOffset.UtcNow), Meta()); + await DataModel.AddChange(ClientId, new EditUserCommentChange(commentId, "third words", DateTimeOffset.UtcNow), Meta()); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // Only the non-latest rows can catch a regression to the projection (the latest edit's payload and the + // current text agree). + activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") + && a.ChangeInfo[0].Target == "original words"); + activities.Should().Contain(a => a.ChangeTypes.Contains("EditUserCommentChange") + && a.ChangeInfo[0].Target == "second words"); + activities.Should().Contain(a => a.ChangeTypes.Contains("EditUserCommentChange") + && a.ChangeInfo[0].Target == "third words"); + } + + [Fact] + public async Task ProjectActivity_ChangeInfo_SenseNumbers_ReflectCurrentPositions() + { + var entryId = await CreateEntry("run"); + await CreateSense(entryId, gloss: "first", order: 2.0); + await CreateSense(entryId, gloss: "early", order: 1.0); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); + // The sense added first (alone and unnumbered at the time) now reads as "first₂": the number locates the + // sense in the entry as it is today, at the cost of "added sense N" no longer meaning it was added at + // position N. Deliberate: the position as of the change isn't recoverable without replaying every + // sibling sense's snapshot per activity row, while the resolver labels a whole page from one batched + // query over the projected Senses table. + activities.Should().Contain(a => a.ChangeTypes.Contains("CreateSenseChange") && a.ChangeInfo[0].Target == "first₂"); + } +} diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs index 3747c77d9e..d22cbe05e9 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs @@ -90,10 +90,10 @@ public async Task ProjectActivity_ChangeInfo_MultipleSenses_NumberedPositionally // 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. + // gloss shows a dotted-circle placeholder for the subscript to attach to. 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)"); + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "◌₃"); } [Fact] diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index 7ff51dae0f..376ae24ff5 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -17,15 +17,15 @@ namespace LcmCrdt; /// Batch-resolves context for each change in a page of activity, so summaries /// can name the entry/sense/possibility a change is about without a per-row lookup. /// -/// Names (headwords, glosses, possibility names): -/// - Use CURRENT data (from the projected tables) +/// In most cases we simply load CURRENT data: (headwords, glosses, possibility names, example-sentence snippets) +/// - From the projected tables /// - Deleted objects are recovered from their latest snapshot. -/// e.g. CreateEntryChange shows the entry's current headword, not the headword at creation time. -/// We don't use names from renames/patch changes, since only some changes carry enough data to compute a label +/// e.g. CreateEntryChange shows the entry's CURRENT headword - NOT the headword at CREATION time. +/// We don't read from the change payload, since only some changes carry enough data to compute a label /// and mixed historical/current rows would be worse than uniform drift. /// -/// Quotes (longer texts e.g. example sentences, comment snippets): -/// - Use the text from the change payload, not the current text. +/// Comment changes are the exception: +/// - Every change carries the full comment text /// /// Degradable: leaves null for types it doesn't resolve. /// @@ -161,7 +161,8 @@ string SemanticDomainLabel(SemanticDomain domain) => string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry) : null; // Gloss + sense number if >1 sense (Mirrors FieldWorks) - // gloss-less sense renders as "({number})" — parenthesized so it can't read as a numeric gloss; + // A gloss-less sense renders as "◌₂" — U+25CC DOTTED CIRCLE, the standard stand-in base for a + // mark with nothing to attach to (this string is data, so a translatable placeholder can't live here). string SenseLabel(Sense sense) { var siblings = sensesByEntry.GetValueOrDefault(sense.EntryId) ?? []; @@ -173,7 +174,7 @@ string SenseLabel(Sense sense) var number = index + 1; var multiple = siblings.Count > 1; if (!string.IsNullOrEmpty(glossText)) return multiple ? glossText + Subscript(number) : glossText; - return multiple ? $"({number})" : ""; + return multiple ? "◌" + Subscript(number) : ""; } // "headword › senseLabel". Degrades to just the headword when the sense has nothing to distinguish it @@ -302,19 +303,16 @@ AddEntryComponentChange a when entries.TryGetValue(a.ComponentEntryId, out var c // headword, target names the sense ("gwa₁ · Added sense senseN" — SenseLabel falls back to a // subscript when the gloss is empty, matching sense-edit summaries). CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId, out var sense) => SenseLabel(sense), - // A create's snippet quotes the text as it was WRITTEN, from the change's own payload: unlike a - // headword or gloss (identifiers that should track current state), a snippet reads as a quote of - // the created content, so a later edit must not rewrite it. Null for an example created blank — - // correct, nothing was written. These arms must precede the entity-type arms below. - CreateExampleSentenceChange c => c.Sentence is { } s ? ExampleSnippet(s, writingSystemOrder) : null, + // Comment quotes: create and edit both carry the complete new text, so every row can quote what was + // written at that moment (the subject already names what it's a comment on). Must precede the + // entity-type fallback below, which would show the current text. CreateUserCommentChange c => CommentSnippet(c.Text), - // Any other change to an example sentence (edit/delete) names the sentence itself as the target: a - // short truncated snippet of the current text, since the full text is too long and is the example's - // only identity. + EditUserCommentChange c => CommentSnippet(c.Text), + // Any change to an example sentence names the sentence itself as the target: a short truncated + // snippet of the current text, since the full text is too long and is the example's only identity. _ when change.Change.EntityType == typeof(ExampleSentence) && examples.TryGetValue(change.EntityId, out var ex) => ExampleSnippet(ex.Sentence, writingSystemOrder), - // Any other change to a comment (edit/delete) names the current comment text as the target: a short - // truncated snippet, like an example sentence (the subject already names what it's a comment on). + // A deleted comment names its last known text, recovered from its final snapshot. _ when change.Change.EntityType == typeof(UserComment) && userComments.TryGetValue(change.EntityId, out var comment) => CommentSnippet(comment.Text), _ => null From 97cf4fa61a7a8fa71bcb7a69aa34e15bc3abcd9b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 14:26:57 +0200 Subject: [PATCH 06/11] Refactor object shapes to not enrich empty properties --- .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 3 +- ...ActivityChangeInfoResolverCoverageTests.cs | 6 +- .../HistoryServiceActivityLabelPolicyTests.cs | 18 +- .../HistoryServiceActivitySubjectTests.cs | 229 +++++++++--------- .../HistoryServiceActivityTests.cs | 14 +- .../LcmCrdt/ActivityChangeInfoResolver.cs | 11 +- backend/FwLite/LcmCrdt/HistoryService.cs | 29 ++- .../src/lib/activity/ActivityItem.svelte | 4 +- .../LcmCrdt/IActivityChange.ts | 14 ++ .../LcmCrdt/IProjectActivity.ts | 8 +- .../generated-types/LcmCrdt/index.ts | 1 + .../viewer/src/lib/history/HistoryView.svelte | 2 +- 12 files changed, 177 insertions(+), 162 deletions(-) create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 4a7eed1571..d4d4427eba 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -184,6 +184,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(FwLiteConfig), typeof(HistoryLineItem), typeof(ProjectActivity), + typeof(ActivityChange), typeof(ActivityChangeInfo), typeof(ActivityAuthor), typeof(ActivityChangeType), @@ -200,8 +201,6 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) ], exportBuilder => exportBuilder.WithPublicProperties()); builder.ExportAsClass().WithCodeGenerator(); - builder.ExportAsInterface() - .WithProperty(a => a.ChangeTypes, p => p.Type()); builder.ExportAsInterface() .WithProperty(t => t.Key, p => p.Type()); builder.ExportAsInterface() diff --git a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs index 5a9ce73212..5eabf40ec1 100644 --- a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs @@ -25,10 +25,10 @@ public async Task EveryChangeEntityType_ResolvesASubject_OrIsIntentionallyDegrad var anyResolved = new Dictionary(); foreach (var activity in activities) { - for (var i = 0; i < activity.Changes.Count; i++) + foreach (var change in activity.Changes) { - var name = EntityTypeName(activity.Changes[i].Change.EntityType); - var hasSubject = activity.ChangeInfo[i].Subject is not null; + var name = EntityTypeName(change.Entity.Change.EntityType); + var hasSubject = change.Info.Subject is not null; allResolved[name] = allResolved.GetValueOrDefault(name, true) && hasSubject; anyResolved[name] = anyResolved.GetValueOrDefault(name) || hasSubject; } diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs index dbc7c9c772..b74a04724b 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs @@ -21,13 +21,13 @@ public async Task ProjectActivity_ChangeInfo_IdentifyingLabels_TrackCurrentState var renamed = await Service.ProjectActivity(0, 100, new ActivityQuery()); // The change that created the entry as "run" now names it by its current headword. - renamed.Should().Contain(a => a.ChangeTypes.Contains("CreateEntryChange") && a.ChangeInfo[0].Subject == "jog"); + renamed.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange) && a.Changes[0].Info.Subject == "jog"); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(entryId), Meta()); var deleted = await Service.ProjectActivity(0, 100, new ActivityQuery()); // Deletion doesn't undo the drift: the label recovered from the last snapshot is the renamed one. - deleted.Should().Contain(a => a.ChangeTypes.Contains("CreateEntryChange") && a.ChangeInfo[0].Subject == "jog"); + deleted.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange) && a.Changes[0].Info.Subject == "jog"); } [Fact] @@ -42,12 +42,12 @@ public async Task ProjectActivity_ChangeInfo_CommentSnippets_QuoteWrittenText_Ne var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); // Only the non-latest rows can catch a regression to the projection (the latest edit's payload and the // current text agree). - activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") - && a.ChangeInfo[0].Target == "original words"); - activities.Should().Contain(a => a.ChangeTypes.Contains("EditUserCommentChange") - && a.ChangeInfo[0].Target == "second words"); - activities.Should().Contain(a => a.ChangeTypes.Contains("EditUserCommentChange") - && a.ChangeInfo[0].Target == "third words"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateUserCommentChange) + && a.Changes[0].Info.Target == "original words"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is EditUserCommentChange) + && a.Changes[0].Info.Target == "second words"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is EditUserCommentChange) + && a.Changes[0].Info.Target == "third words"); } [Fact] @@ -63,6 +63,6 @@ public async Task ProjectActivity_ChangeInfo_SenseNumbers_ReflectCurrentPosition // position N. Deliberate: the position as of the change isn't recoverable without replaying every // sibling sense's snapshot per activity row, while the resolver labels a whole page from one batched // query over the projected Senses table. - activities.Should().Contain(a => a.ChangeTypes.Contains("CreateSenseChange") && a.ChangeInfo[0].Target == "first₂"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateSenseChange) && a.Changes[0].Info.Target == "first₂"); } } diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs index d22cbe05e9..aa5b13419f 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs @@ -1,4 +1,5 @@ using LcmCrdt.Changes; +using LcmCrdt.Changes.Comments; using LcmCrdt.Changes.Entries; using SIL.Harmony.Core; using SystemTextJsonPatch; @@ -26,8 +27,8 @@ public async Task ProjectActivity_ChangeInfo_AddsHomographSubscriptToSubject_Onl 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₂"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "plain"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "homograph₂"); } [Fact] @@ -47,7 +48,7 @@ public async Task ProjectActivity_ChangeInfo_SkipsAudioAndUsesAnotherWritingSyst var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "nyumba"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "nyumba"); } [Fact] @@ -63,7 +64,7 @@ public async Task ProjectActivity_ChangeInfo_AppliesMorphTypeMarkers() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "-ness"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "-ness"); } [Fact] @@ -75,7 +76,7 @@ public async Task ProjectActivity_ChangeInfo_LoneSense_HasNoSenseNumber() 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"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "run" && a.Changes[0].Info.Target == "to run"); } [Fact] @@ -91,9 +92,9 @@ public async Task ProjectActivity_ChangeInfo_MultipleSenses_NumberedPositionally // 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 a dotted-circle placeholder for the subscript to attach to. - 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 == "◌₃"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "run" && a.Changes[0].Info.Target == "to run₁"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "run" && a.Changes[0].Info.Target == "a jog₂"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "run" && a.Changes[0].Info.Target == "◌₃"); } [Fact] @@ -107,10 +108,10 @@ public async Task ProjectActivity_ChangeInfo_EmptyEntryHasNullSubject() // No displayable headword: the subject and owning-entry headword are null (the frontend renders its // placeholder, never "(Unknown)"), but the change still resolves to its owning entry. - activities.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].OwningEntryId == entryId - && a.ChangeInfo[0].Subject == null - && a.ChangeInfo[0].OwningEntryHeadword == null); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.OwningEntryId == entryId + && a.Changes[0].Info.Subject == null + && a.Changes[0].Info.OwningEntryHeadword == null); } [Fact] @@ -127,9 +128,9 @@ public async Task ProjectActivity_ChangeInfo_UnlabellableSenseHasNullSubject() // 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].OwningEntryId == entryId - && a.ChangeInfo[0].Subject == null); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.OwningEntryId == entryId + && a.Changes[0].Info.Subject == null); } // --- Possibilities, writing systems, and custom views name themselves by their display name, and recover @@ -143,17 +144,17 @@ public async Task ProjectActivity_ChangeInfo_NamesPartOfSpeechSubject_EvenAfterD var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); // A possibility names itself by its display name and has no owning entry (so no owning-entry headword either). - live.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "Verb" - && a.ChangeInfo[0].OwningEntryId == null - && a.ChangeInfo[0].OwningEntryHeadword == null); + live.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "Verb" + && a.Changes[0].Info.OwningEntryId == null + && a.Changes[0].Info.OwningEntryHeadword == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); // The delete drops it from the projection; its label is recovered from the last snapshot. - afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:PartOfSpeech") - && a.ChangeInfo[0].Subject == "Verb"); + afterDelete.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "Verb"); } [Fact] @@ -164,15 +165,15 @@ public async Task ProjectActivity_ChangeInfo_NamesSemanticDomainSubject_EvenAfte var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); // A domain shows as "code name". - live.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "5.2 Food" - && a.ChangeInfo[0].OwningEntryId == null); + live.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "5.2 Food" + && a.Changes[0].Info.OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); - afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:SemanticDomain") - && a.ChangeInfo[0].Subject == "5.2 Food"); + afterDelete.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "5.2 Food"); } [Fact] @@ -182,15 +183,15 @@ public async Task ProjectActivity_ChangeInfo_NamesPublicationSubject_EvenAfterDe await DataModel.AddChange(ClientId, new CreatePublicationChange(id, new MultiString { ["en"] = "Main Dictionary" }), Meta()); var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); - live.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "Main Dictionary" - && a.ChangeInfo[0].OwningEntryId == null); + live.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "Main Dictionary" + && a.Changes[0].Info.OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); - afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:Publication") - && a.ChangeInfo[0].Subject == "Main Dictionary"); + afterDelete.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "Main Dictionary"); } [Fact] @@ -200,15 +201,15 @@ public async Task ProjectActivity_ChangeInfo_NamesComplexFormTypeSubject_EvenAft await DataModel.AddChange(ClientId, new CreateComplexFormType(id, new MultiString { ["en"] = "Compound" }), Meta()); var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); - live.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "Compound" - && a.ChangeInfo[0].OwningEntryId == null); + live.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "Compound" + && a.Changes[0].Info.OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); - afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:ComplexFormType") - && a.ChangeInfo[0].Subject == "Compound"); + afterDelete.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "Compound"); } [Fact] @@ -227,16 +228,16 @@ public async Task ProjectActivity_ChangeInfo_NamesWritingSystemSubject_EvenAfter var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); // A writing system names itself by its plain display name and has no owning entry. - live.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "French" - && a.ChangeInfo[0].OwningEntryId == null); + live.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "French" + && a.Changes[0].Info.OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); // The delete drops it from the projection; its name is recovered from the last snapshot. - afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:WritingSystem") - && a.ChangeInfo[0].Subject == "French"); + afterDelete.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "French"); } [Fact] @@ -251,15 +252,15 @@ public async Task ProjectActivity_ChangeInfo_NamesCustomViewSubject_EvenAfterDel }), Meta()); var live = await Service.ProjectActivity(0, 100, new ActivityQuery()); - live.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "My View" - && a.ChangeInfo[0].OwningEntryId == null); + live.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "My View" + && a.Changes[0].Info.OwningEntryId == null); await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(id), Meta()); var afterDelete = await Service.ProjectActivity(0, 100, new ActivityQuery()); - afterDelete.Should().Contain(a => a.ChangeTypes.Contains("delete:CustomView") - && a.ChangeInfo[0].Subject == "My View"); + afterDelete.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "My View"); } [Fact] @@ -273,7 +274,7 @@ public async Task ProjectActivity_ChangeInfo_NamesMorphTypeSubject() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "suffix"); + activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.Subject == "suffix"); } // --- Deleted entries and senses recover their labels from the last snapshot. --- @@ -287,9 +288,9 @@ public async Task ProjectActivity_ChangeInfo_DeletedEntry_StillHasHeadword() 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].OwningEntryHeadword == "run"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "run" + && a.Changes[0].Info.OwningEntryHeadword == "run"); } [Fact] @@ -303,9 +304,9 @@ public async Task ProjectActivity_ChangeInfo_DeletedSense_StillHasLabel_WithoutP // Removing a sense mirrors adding one: the entry is the subject, the removed sense the target. // Recovered from its snapshot; absent from the live sibling list, so the target gloss carries no number. - activities.Should().Contain(a => a.ChangeTypes.Contains("delete:Sense") - && a.ChangeInfo[0].Subject == "run" - && a.ChangeInfo[0].Target == "to run"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "run" + && a.Changes[0].Info.Target == "to run"); } // --- Reference changes name the entity holding the reference (subject) and the referenced object (target), @@ -327,9 +328,9 @@ public async Task ProjectActivity_ChangeInfo_NamesTheReferencedPartOfSpeech() 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"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.Target == "Noun"); } [Fact] @@ -344,9 +345,9 @@ public async Task ProjectActivity_ChangeInfo_AddSemanticDomain_NamesAddedDomain( var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeTypes.Contains("AddSemanticDomainChange") - && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].Target == "5.2 Food"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is AddSemanticDomainChange) + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.Target == "5.2 Food"); } [Fact] @@ -362,9 +363,9 @@ public async Task ProjectActivity_ChangeInfo_RemoveSemanticDomain_NamesRemovedDo 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"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.Target == "5.2 Food"); } [Fact] @@ -378,9 +379,9 @@ public async Task ProjectActivity_ChangeInfo_AddPublication_NamesAddedPublicatio var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeTypes.Contains("AddPublicationChange") - && a.ChangeInfo[0].Subject == "run" - && a.ChangeInfo[0].Target == "Main Dictionary"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is AddPublicationChange) + && a.Changes[0].Info.Subject == "run" + && a.Changes[0].Info.Target == "Main Dictionary"); } [Fact] @@ -396,9 +397,9 @@ public async Task ProjectActivity_ChangeInfo_RemovePublication_NamesRemovedPubli 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"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "run" + && a.Changes[0].Info.Target == "Main Dictionary"); } [Fact] @@ -412,9 +413,9 @@ public async Task ProjectActivity_ChangeInfo_AddComplexFormType_NamesAddedType() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - activities.Should().Contain(a => a.ChangeTypes.Contains("AddComplexFormTypeChange") - && a.ChangeInfo[0].Subject == "blackbird" - && a.ChangeInfo[0].Target == "Compound"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is AddComplexFormTypeChange) + && a.Changes[0].Info.Subject == "blackbird" + && a.Changes[0].Info.Target == "Compound"); } [Fact] @@ -429,9 +430,9 @@ public async Task ProjectActivity_ChangeInfo_RemoveComplexFormType_NamesRemovedT 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"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "blackbird" + && a.Changes[0].Info.Target == "Compound"); } [Fact] @@ -444,10 +445,10 @@ public async Task ProjectActivity_ChangeInfo_NamesComplexFormComponentSubject_An 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].OwningEntryId == complexFormId - && a.ChangeInfo[0].Target == "bird"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "blackbird" + && a.Changes[0].Info.OwningEntryId == complexFormId + && a.Changes[0].Info.Target == "bird"); } [Fact] @@ -462,9 +463,9 @@ public async Task ProjectActivity_ChangeInfo_RemovedComponent_StillNamesEndpoint 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"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "blackbird" + && a.Changes[0].Info.Target == "bird"); } [Fact] @@ -482,11 +483,11 @@ public async Task ProjectActivity_ChangeInfo_NamesExampleSentenceSubject() // An example resolves to its parent sense's subject and its owning entry (id + headword); the target is a // short snippet of the sentence text (here short enough to show whole). - activities.Should().Contain(a => a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].OwningEntryId == entryId - && a.ChangeInfo[0].OwningEntryHeadword == "run" - && a.ChangeInfo[0].Target == "I run daily"); + activities.Should().Contain(a => a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.OwningEntryId == entryId + && a.Changes[0].Info.OwningEntryHeadword == "run" + && a.Changes[0].Info.Target == "I run daily"); } [Fact] @@ -505,9 +506,9 @@ public async Task ProjectActivity_ChangeInfo_DeletedExample_StillHasSentenceSnip var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); // The deleted example is gone from the projection; its snippet is recovered from the last snapshot. - activities.Should().Contain(a => a.ChangeTypes.Contains("delete:ExampleSentence") - && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].Target == "I run daily"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.Target == "I run daily"); } // --- Comments resolve to whatever their thread is attached to (subject), with the comment text as a @@ -522,9 +523,9 @@ public async Task ProjectActivity_ChangeInfo_CommentThread_NamesCommentedEntry() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); // A thread reads at the level of the thing it's on: the commented entry's headword and its owning entry. - activities.Should().Contain(a => a.ChangeTypes.Contains("CreateCommentThreadChange") - && a.ChangeInfo[0].Subject == "run" - && a.ChangeInfo[0].OwningEntryId == entryId); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateCommentThreadChange) + && a.Changes[0].Info.Subject == "run" + && a.Changes[0].Info.OwningEntryId == entryId); } [Fact] @@ -538,10 +539,10 @@ public async Task ProjectActivity_ChangeInfo_UserComment_NamesCommentedSense_And var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); // The comment resolves to its sense (subject + owning entry) with the comment text as the target. - activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") - && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].OwningEntryId == entryId - && a.ChangeInfo[0].Target == "looks wrong"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateUserCommentChange) + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.OwningEntryId == entryId + && a.Changes[0].Info.Target == "looks wrong"); } [Fact] @@ -554,8 +555,8 @@ public async Task ProjectActivity_ChangeInfo_UserComment_TruncatesLongText() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); // Long comment text is truncated to the example-snippet budget, backing off to the last whole word. - activities.Should().Contain(a => a.ChangeTypes.Contains("CreateUserCommentChange") - && a.ChangeInfo[0].Target == "this gloss looks…"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateUserCommentChange) + && a.Changes[0].Info.Target == "this gloss looks…"); } [Fact] @@ -569,9 +570,9 @@ public async Task ProjectActivity_ChangeInfo_DeletedComment_StillHasTextSnippet( var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); // The deleted comment is gone from the projection; its snippet is recovered from the last snapshot. - activities.Should().Contain(a => a.ChangeTypes.Contains("delete:UserComment") - && a.ChangeInfo[0].Subject == "run" - && a.ChangeInfo[0].Target == "looks wrong"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange) + && a.Changes[0].Info.Subject == "run" + && a.Changes[0].Info.Target == "looks wrong"); } // --- Reorder changes name the parent as the subject and (where applicable) the moved child as the target. --- @@ -597,8 +598,8 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentEntryAndMovedSen // The reorder names the parent entry as the subject and the moved sense's gloss as the target. // Pin to the reorder change: the create-sense commit resolves to the same subject/target. - activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:Sense") - && a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "run" && a.ChangeInfo[0].Target == "to run"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is LcmCrdt.Changes.SetOrderChange) + && a.Changes.Count == 1 && a.Changes[0].Info.Subject == "run" && a.Changes[0].Info.Target == "to run"); } [Fact] @@ -619,12 +620,12 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesParentSenseAndMovedExa // Reordering an example names its parent sense as the subject, the example's owning entry (id + headword), // and a snippet of the moved example as the target. // Pin to the reorder change: the create-example commit resolves to the same subject/owning entry. - activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:ExampleSentence") - && a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "run › to run" - && a.ChangeInfo[0].OwningEntryId == entryId - && a.ChangeInfo[0].OwningEntryHeadword == "run" - && a.ChangeInfo[0].Target == "I run"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is LcmCrdt.Changes.SetOrderChange) + && a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "run › to run" + && a.Changes[0].Info.OwningEntryId == entryId + && a.Changes[0].Info.OwningEntryHeadword == "run" + && a.Changes[0].Info.Target == "I run"); } [Fact] @@ -639,10 +640,10 @@ public async Task ProjectActivity_ChangeInfo_Reorder_NamesComplexFormAndMovedCom // Reordering a component names the complex form as subject and the moved component as target. // Pin to the reorder change: the add-component commit resolves to the same subject/target. - activities.Should().Contain(a => a.ChangeTypes.Contains("SetOrderChange:ComplexFormComponent") - && a.ChangeInfo.Count == 1 - && a.ChangeInfo[0].Subject == "blackbird" - && a.ChangeInfo[0].OwningEntryId == complexFormId - && a.ChangeInfo[0].Target == "bird"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is LcmCrdt.Changes.SetOrderChange) + && a.Changes.Count == 1 + && a.Changes[0].Info.Subject == "blackbird" + && a.Changes[0].Info.OwningEntryId == complexFormId + && a.Changes[0].Info.Target == "bird"); } } diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs index 10a95f9061..9e8ac62767 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -116,9 +116,9 @@ public async Task ProjectActivity_FiltersByChangeTypeKeys() var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange)])); - activities.Should().OnlyContain(a => a.ChangeTypes.Contains(nameof(CreateEntryChange))); + activities.Should().OnlyContain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange)); activities.Should().HaveCountGreaterThanOrEqualTo(1); - activities.Should().NotContain(a => a.ChangeTypes.Contains(nameof(CreatePublicationChange))); + activities.Should().NotContain(a => a.Changes.Any(c => c.Entity.Change is CreatePublicationChange)); } [Fact] @@ -128,22 +128,22 @@ public async Task ProjectActivity_ChangeTypeKeys_FiltersMultipleTypes() 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())) - .Should().Contain(a => a.ChangeTypes.Contains(nameof(CreatePartOfSpeechChange))); + .Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreatePartOfSpeechChange)); 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))); + activities.Should().OnlyContain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange || c.Entity.Change is CreatePublicationChange)); + activities.Should().NotContain(a => a.Changes.Any(c => c.Entity.Change is CreatePartOfSpeechChange)); } [Fact] - public async Task ProjectActivity_IncludesChangeTypes() + public async Task ProjectActivity_IncludesChanges() { await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); var activity = (await Service.ProjectActivity(0, 1)).Single(); - activity.ChangeTypes.Should().Contain("CreateEntryChange"); + activity.Changes.Should().ContainSingle(c => c.Entity.Change is CreateEntryChange); } } diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index 376ae24ff5..d9028e5d16 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -31,7 +31,7 @@ namespace LcmCrdt; /// internal static class ActivityChangeInfoResolver { - public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities, WritingSystems writingSystems) + public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities, WritingSystems writingSystems) { var entryIds = new HashSet(); var senseIds = new HashSet(); @@ -187,10 +187,11 @@ string SenseLabel(Sense sense) return string.IsNullOrEmpty(senseLabel) ? headword : $"{headword} › {senseLabel}"; } - return [.. activities.Select(activity => activity with - { - ChangeInfo = [.. activity.Changes.Select(change => Build(change, Headword))], - })]; + return [.. activities.Select(activity => new ProjectActivity( + activity.CommitId, + activity.Timestamp, + [.. activity.Changes.Select(change => new ActivityChange(change, Build(change, Headword)))], + activity.Metadata))]; ActivityChangeInfo Build(ChangeEntity change, Func headword) { diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index f89a334076..013d06e33f 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -46,18 +46,24 @@ public static class ActivityFilterKeys /// public record ActivityChangeInfo(string? Subject, Guid? OwningEntryId, string? Target = null, string? OwningEntryHeadword = null); +public record ActivityChange(ChangeEntity Entity, ActivityChangeInfo Info); + public record ProjectActivity( Guid CommitId, DateTimeOffset Timestamp, - List> Changes, + IReadOnlyList Changes, CommitMetadata Metadata) { public string ChangeName => HistoryService.ChangesNameHelper(Changes); - public string[] ChangeTypes { get; } = [.. Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct()]; - /// Resolved display info per change, parallel to by index. Set during enrichment. - public IReadOnlyList ChangeInfo { get; init; } = []; } +/// The query-shaped activity row, before turns it into a . +internal record UnresolvedActivity( + Guid CommitId, + DateTimeOffset Timestamp, + List> Changes, + CommitMetadata Metadata); + public record ChangeContext( Guid CommitId, int ChangeIndex, @@ -154,7 +160,7 @@ public async Task ProjectActivity(int skip = 0, int take = 10 commits = ApplyActivitySort(commits, query.Sort); var queryable = from commit in commits.Skip(skip).Take(take) - select new ProjectActivity(commit.Id, + select new UnresolvedActivity(commit.Id, commit.HybridDateTime.DateTime, commit.ChangeEntities.ToList(), commit.Metadata); @@ -254,9 +260,6 @@ public static string GetChangeTypeKeyFromType(Type changeType) return changeType.Name; } - internal static string GetChangeTypeKey(IChange change) => - GetChangeTypeKeyFromType(change.GetType()); - public static string ChangeTypeLabel(Type changeType) { if (changeType.IsGenericType && changeType.Name.Contains("JsonPatch", StringComparison.Ordinal)) @@ -400,14 +403,14 @@ private async IAsyncEnumerable GetAffectedEntryIds(ChangeEntity c } } - public static string ChangesNameHelper(List> changeEntities) + public static string ChangesNameHelper(IReadOnlyList changes) { - return changeEntities switch + return changes switch { { Count: 0 } => "No changes", - { Count: 1 } => ChangeNameHelper(changeEntities[0].Change), - { Count: > 10 } => $"{changeEntities.Count} changes", - { Count: var count } => $"{ChangeNameHelper(changeEntities[0].Change)} (+{count - 1} other change{(count > 2 ? "s" : "")})", + { Count: 1 } => ChangeNameHelper(changes[0].Entity.Change), + { Count: > 10 } => $"{changes.Count} changes", + { Count: var count } => $"{ChangeNameHelper(changes[0].Entity.Change)} (+{count - 1} other change{(count > 2 ? "s" : "")})", }; } diff --git a/frontend/viewer/src/lib/activity/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 3dc4673c04..106b591562 100644 --- a/frontend/viewer/src/lib/activity/ActivityItem.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItem.svelte @@ -49,8 +49,8 @@ const historyService = useHistoryService(); let openHistoryId = $state() - const changes = $derived(!historyService.loaded ? undefined : activity.changes.map(change => { - return new ChangeWithLazyContext(change, activity, () => historyService.loadChangeContext(activity.commitId, change.index)); + const changes = $derived(!historyService.loaded ? undefined : activity.changes.map(({entity}) => { + return new ChangeWithLazyContext(entity, activity, () => historyService.loadChangeContext(activity.commitId, entity.index)); })); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts new file mode 100644 index 0000000000..75fc5e5cce --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {IChangeEntity} from '../SIL/Harmony/Core/IChangeEntity'; +import type {IActivityChangeInfo} from './IActivityChangeInfo'; + +export interface IActivityChange +{ + entity: IChangeEntity; + info: IActivityChangeInfo; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts index 426917d253..6cf731f56f 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts @@ -3,19 +3,15 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. -import type {IChangeEntity} from '../SIL/Harmony/Core/IChangeEntity'; +import type {IActivityChange} from './IActivityChange'; import type {ICommitMetadata} from '../SIL/Harmony/Core/ICommitMetadata'; -import type {ChangeType} from './ChangeType'; -import type {IActivityChangeInfo} from './IActivityChangeInfo'; export interface IProjectActivity { commitId: string; timestamp: string; - changes: IChangeEntity[]; + changes: IActivityChange[]; metadata: ICommitMetadata; changeName: string; - changeTypes: ChangeType[]; - changeInfo: IActivityChangeInfo[]; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts index 99032a877b..f2e7245c16 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts @@ -6,6 +6,7 @@ export * from './UserProjectRole' export * from './IChangeContext' export * from './IActivityAuthor' export * from './IActivityChangeType' +export * from './IActivityChange' export * from './IActivityChangeInfo' export * from './IActivityQuery' export * from './ActivitySort' diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index ca3dedb9bd..7a26062199 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -103,7 +103,7 @@
{#if record} - + {/if}
From 06f86d3737d69c73d19f9e32e8738ec6e03ca6c9 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 14:33:59 +0200 Subject: [PATCH 07/11] Finalize comments and test coverage --- ...tyChangeInfoResolverExampleSnippetTests.cs | 2 + .../LcmCrdt/ActivityChangeInfoResolver.cs | 82 +++++++------------ 2 files changed, 33 insertions(+), 51 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs index a5b6d4206b..0d85298cfd 100644 --- a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs @@ -16,6 +16,8 @@ public class ActivityChangeInfoResolverExampleSnippetTests [InlineData("The quick brown fox jumps over the lazy dog", "The quick brown fox…")] // 4 words // A single long token with no space to break on → hard grapheme cut at the budget. [InlineData("Supercalifragilisticexpialidocious is long", "Supercalifragilistic…")] + // A space in the first half of the window is ignored — backing off to it would collapse the snippet to "ab…". + [InlineData("ab Supercalifragilisticexpialidocious", "ab Supercalifragilis…")] public void ExampleSnippet_TruncatesSpacedText(string input, string expected) { ActivityChangeInfoResolver.ExampleSnippet(Sentence(input)).Should().Be(expected); diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index d9028e5d16..cee701aeab 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Globalization; using System.Linq.Expressions; using System.Text.RegularExpressions; @@ -112,8 +113,7 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea var senses = await LoadWithRecovery(db, senseIds, LoadSenses); 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. + // For looking up sense numbers var sensesByEntry = await LoadSensesByEntry(db, [.. senses.Values.Select(s => s.EntryId)]); var entries = await LoadWithRecovery(db, entryIds, LoadEntries); @@ -126,26 +126,22 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea w => new WritingSystem { Id = w.Id, WsId = w.WsId, Name = w.Name, Abbreviation = w.Abbreviation, Font = w.Font, Type = w.Type }); var customViews = await LoadNamed(db, customViewIds, v => new CustomView { Id = v.Id, Name = v.Name }); - // 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. + // Used to render headwords, so skip the load entirely when no entry is being resolved. var morphLookup = entryIds.Count == 0 ? [] : (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()); - // The project's writing-system display order; the label helpers below pick the first non-empty - // alternative in this order rather than alphabetically by writing-system code. + // A dictionary for quick order lookup, so we can iterate multi-strings instead of known WS's + // in case we only have values for "missing"/deleted WS's var writingSystemOrder = BuildWsOrder(writingSystems); - // The alternative to show for a multi-writing-system name: first non-empty in configured order (see BestAlternative). - string? Label(MultiString multiString) => BestAlternative(multiString.Values, writingSystemOrder, v => v?.Trim()); + string? Label(MultiString multiString) => BestAlternative(multiString.Values, writingSystemOrder, v => v); - // Best non-audio headword alternative (configured order, like 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. string? DisplayHeadword(Entry entry) { + morphLookup.TryGetValue(entry.MorphType, out var morphData); var headword = BestAlternative( EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values.Where(kvp => !kvp.Key.IsAudio), writingSystemOrder, @@ -154,15 +150,15 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword; } - // The app shows a domain as "code name" (e.g. "5.2 Food"); the code alone is too cryptic to identify it. + // The combination of code + name is the standard label for a semantic domain string SemanticDomainLabel(SemanticDomain domain) => Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code; string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry) : null; // Gloss + sense number if >1 sense (Mirrors FieldWorks) - // A gloss-less sense renders as "◌₂" — U+25CC DOTTED CIRCLE, the standard stand-in base for a - // mark with nothing to attach to (this string is data, so a translatable placeholder can't live here). + // A gloss-less sense renders as "◌₂", which is a tad quircky. + // Rejected alternatives: '(2)' is too different from subscript. '_₂' risks making the subscript hard to read. '-₂' dash/emdash could read as formatting. string SenseLabel(Sense sense) { var siblings = sensesByEntry.GetValueOrDefault(sense.EntryId) ?? []; @@ -170,11 +166,8 @@ string SenseLabel(Sense sense) 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 ? "◌" + Subscript(number) : ""; + if (index < 0 || siblings.Count <= 1) return glossText ?? ""; + return (glossText ?? "◌") + Subscript(index + 1); } // "headword › senseLabel". Degrades to just the headword when the sense has nothing to distinguish it @@ -232,12 +225,15 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw // Adding or removing a sense is a change to its parent entry's structure, so both read as // entry-level ("headword · Added sense senseN" / "· Removed sense senseN"): the subject is the // parent entry's headword and the sense identifier goes to Target. - // Field edits keep the "headword › senseLabel" subject so they read as - // sense-level (the sense itself is what's constant, a field is what changed). if (change.Change is CreateSenseChange or DeleteChange) return (headword(sense.EntryId), sense.EntryId); + // Field edits keep the "headword › senseLabel" subject so they read as + // sense-level (the sense itself is what's constant, a field is what changed). return (QualifiedSenseLabel(headword(sense.EntryId), sense), sense.EntryId); case nameof(ExampleSentence) when examples.TryGetValue(id, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): + // Example edits keep the "headword › senseLabel" subject. + // Unlike senses they don't have a "name" and a 3-level subject would be quite verbose. + // Consumers can treat the target as part of the subject if they want return (QualifiedSenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); case nameof(ComplexFormComponent) when components.TryGetValue(id, out var component): return (headword(component.ComplexFormEntryId), component.ComplexFormEntryId); @@ -265,9 +261,6 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw } } - // A comment resolves to whatever its thread is attached to — the entry headword, the "headword › gloss" - // sense label, or the example's sense label — with that entity's owning entry. Mirrors the - // Entry/Sense/ExampleSentence arms above so a comment reads at the same level as an edit to its subject. (string? Subject, Guid? OwningEntryId) CommentSubject(CommentThread thread, Func headword) { var subjectId = thread.SubjectId; @@ -284,7 +277,7 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw } } - // The item a change references only by id (the part of speech set, the domain/publication/type removed). + // The item a change references (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), @@ -301,8 +294,7 @@ AddEntryComponentChange a when entries.TryGetValue(a.ComponentEntryId, out var c && components.TryGetValue(change.EntityId, out var link) && entries.TryGetValue(link.ComponentEntryId, out var component) => DisplayHeadword(component), // Sense create/delete pair with the ResolveSubject override above: subject is the parent entry - // headword, target names the sense ("gwa₁ · Added sense senseN" — SenseLabel falls back to a - // subscript when the gloss is empty, matching sense-edit summaries). + // headword, target names the sense ("gwa₁ · Added sense senseN"). CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId, out var sense) => SenseLabel(sense), // Comment quotes: create and edit both carry the complete new text, so every row can quote what was // written at that moment (the subject already names what it's a comment on). Must precede the @@ -332,11 +324,8 @@ private static async Task> LoadWithRecovery(ICrdtDbContex return loaded; } - // 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 window query picks each entity's - // newest snapshot without pulling its whole history. + // Bulk loads snapshots for things not available in the projected tables (because they're deleted), + // but that we still want to give a name to. private static async Task RecoverDeleted(ICrdtDbContext db, HashSet ids, Dictionary loaded) where T : class, IObjectWithId { @@ -369,8 +358,7 @@ select row.s } } - // 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. + // Per-type projected loads: only the columns the resolver uses for labeling private static async Task> LoadEntries(ICrdtDbContext db, HashSet ids) { @@ -426,7 +414,7 @@ private static async Task> LoadUserComments(ICrdtD return loaded.ToDictionary(c => c.Id); } - // Only the thread's subject (what it's attached to) is needed to label a comment; its comment list isn't. + // Only the thread's subject (what it's attached to) is used to label the thread; not its comments. private static async Task> LoadCommentThreads(ICrdtDbContext db, HashSet ids) { if (ids.Count == 0) return []; @@ -484,11 +472,8 @@ private static string Subscript(int number) return buffer[..charsWritten].ToString(); } - private static readonly IReadOnlyDictionary EmptyWsOrder = new Dictionary(); - - // The alternative to display from a multi-writing-system value: the first non-empty one in the project's - // configured writing-system order, falling back to WS code so the pick stays deterministic for a writing - // system not in the order map (and in tests, where none is loaded). Null when every alternative is empty. + /// iterates alternatives in WS-order rather than just WS's just in case there's only data for a "missing" WS + /// the best (based on WS order) non-empty alternative or null private static string? BestAlternative( IEnumerable> alternatives, IReadOnlyDictionary wsOrder, @@ -498,7 +483,7 @@ private static string Subscript(int number) .OrderBy(kvp => wsOrder.GetValueOrDefault(kvp.Key, int.MaxValue)) .ThenBy(kvp => kvp.Key.Code)) { - var text = render(kvp.Value); + var text = render(kvp.Value)?.Trim(); if (!string.IsNullOrEmpty(text)) return text; } return null; @@ -506,7 +491,7 @@ private static string Subscript(int number) // Rank each writing system by the project's configured display order (GetWritingSystems already sorts by // Order), so a label shows the alternative the editor lists first. A WsId shared by a vernacular and an - // analysis system keeps its first rank; cross-type interleaving doesn't matter since a field holds one type. + // analysis system keeps its first rank; cross-type interleaving doesn't really matter since a field holds one type. private static Dictionary BuildWsOrder(WritingSystems writingSystems) { var rank = new Dictionary(); @@ -516,27 +501,22 @@ private static Dictionary BuildWsOrder(WritingSystems writ } // Max grapheme clusters shown in an example-sentence snippet before it's truncated. - // Small on purpose: it acts as a name/subject for the parent entity - // Should arguably be handled in CSS, but it's nice having something subject-like to populate the Subject prop with. - public const int TextSnippetBudget = 20; + // Small on purpose: it acts as a name/summary for the entity. + // Truncation should arguably be handled in CSS. We'll see. + internal const int TextSnippetBudget = 20; private static readonly Regex WhitespaceRun = new(@"\s+", RegexOptions.Compiled); - // A short one-line label for an example sentence: the best non-empty writing system's text (in configured - // order, like Label), flattened from rich text and whitespace-collapsed, then truncated to a grapheme - // budget. Null when no writing system has displayable text (mirrors the null gloss/headword degradation). internal static string? ExampleSnippet(RichMultiString sentence, IReadOnlyDictionary? wsOrder = null) { - return BestAlternative(sentence, wsOrder ?? EmptyWsOrder, richString => + return BestAlternative(sentence, wsOrder ?? ImmutableDictionary.Empty, richString => { var plainText = Truncate(richString?.GetPlainText(), TextSnippetBudget); return string.IsNullOrWhiteSpace(plainText) ? null : plainText; }); } - // A short one-line label for a comment: its (plain-string) text, whitespace-collapsed and truncated to the - // same budget as an example snippet. Null when the comment has no displayable text. private static string? CommentSnippet(string? text) => Truncate(text, TextSnippetBudget); // Truncate to at most `budget` grapheme clusters (never splitting a surrogate pair or combining mark), From 647b08a4b21bf36c7eb7a27f754f676a6278972f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 15:09:52 +0200 Subject: [PATCH 08/11] Reorder and simplify --- .../LcmCrdt/ActivityChangeInfoResolver.cs | 251 +++++++++--------- 1 file changed, 119 insertions(+), 132 deletions(-) diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index cee701aeab..31c2041b68 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -137,106 +137,59 @@ public static async Task ResolveAsync(ICrdtDbContext db, IRea // in case we only have values for "missing"/deleted WS's var writingSystemOrder = BuildWsOrder(writingSystems); - string? Label(MultiString multiString) => BestAlternative(multiString.Values, writingSystemOrder, v => v); - - string? DisplayHeadword(Entry entry) - { - morphLookup.TryGetValue(entry.MorphType, out var morphData); - var headword = BestAlternative( - EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values.Where(kvp => !kvp.Key.IsAudio), - writingSystemOrder, - v => v?.Trim()); - if (string.IsNullOrEmpty(headword)) return null; - return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword; - } - - // The combination of code + name is the standard label for a semantic domain - string SemanticDomainLabel(SemanticDomain domain) => - Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code; - - string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry) : null; - - // Gloss + sense number if >1 sense (Mirrors FieldWorks) - // A gloss-less sense renders as "◌₂", which is a tad quircky. - // Rejected alternatives: '(2)' is too different from subscript. '_₂' risks making the subscript hard to read. '-₂' dash/emdash could read as formatting. - string SenseLabel(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 || siblings.Count <= 1) return glossText ?? ""; - return (glossText ?? "◌") + Subscript(index + 1); - } - - // "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? QualifiedSenseLabel(string? headword, Sense sense) - { - var senseLabel = SenseLabel(sense); - if (headword is null) return string.IsNullOrEmpty(senseLabel) ? null : senseLabel; - return string.IsNullOrEmpty(senseLabel) ? headword : $"{headword} › {senseLabel}"; - } - return [.. activities.Select(activity => new ProjectActivity( activity.CommitId, activity.Timestamp, - [.. activity.Changes.Select(change => new ActivityChange(change, Build(change, Headword)))], + [.. activity.Changes.Select(change => new ActivityChange(change, Build(change)))], activity.Metadata))]; - ActivityChangeInfo Build(ChangeEntity change, Func headword) + ActivityChangeInfo Build(ChangeEntity change) { var info = ResolveReorder(change); if (info is null) { - var (subject, owningEntryId) = ResolveSubject(change, headword); + var (subject, owningEntryId) = ResolveSubject(change); info = new ActivityChangeInfo(subject, owningEntryId, TargetLabel(change)); } - return info.OwningEntryId is { } owningId ? info with { OwningEntryHeadword = headword(owningId) } : info; + return info.OwningEntryId is { } owningId ? info with { OwningEntryHeadword = Headword(owningId) } : 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) + ActivityChangeInfo? ResolveReorder(ChangeEntity change) => change.Change switch { - switch (change.Change) - { - case Changes.SetOrderChange when senses.TryGetValue(change.EntityId, out var sense): - return new ActivityChangeInfo(Headword(sense.EntryId), sense.EntryId, SenseLabel(sense)); - case Changes.SetOrderChange when examples.TryGetValue(change.EntityId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): - return new ActivityChangeInfo(QualifiedSenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId, ExampleSnippet(ex.Sentence, writingSystemOrder)); - 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; - } - } + Changes.SetOrderChange when senses.TryGetValue(change.EntityId, out var sense) => + new ActivityChangeInfo(Headword(sense.EntryId), sense.EntryId, SenseLabel(sense)), + Changes.SetOrderChange when examples.TryGetValue(change.EntityId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense) => + new ActivityChangeInfo(QualifiedSenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId, ExampleSnippet(ex.Sentence, writingSystemOrder)), + Changes.SetOrderChange when components.TryGetValue(change.EntityId, out var comp) => + new ActivityChangeInfo(Headword(comp.ComplexFormEntryId), comp.ComplexFormEntryId, + entries.ContainsKey(comp.ComponentEntryId) ? Headword(comp.ComponentEntryId) : null), + _ => null + }; - (string? Subject, Guid? OwningEntryId) ResolveSubject(ChangeEntity change, Func headword) + (string? Subject, Guid? OwningEntryId) ResolveSubject(ChangeEntity change) { var id = change.EntityId; switch (change.Change.EntityType.Name) { case nameof(Entry): - return (headword(id), id); + return (Headword(id), id); case nameof(Sense) when senses.TryGetValue(id, out var sense): // Adding or removing a sense is a change to its parent entry's structure, so both read as // entry-level ("headword · Added sense senseN" / "· Removed sense senseN"): the subject is the // parent entry's headword and the sense identifier goes to Target. if (change.Change is CreateSenseChange or DeleteChange) - return (headword(sense.EntryId), sense.EntryId); + return (Headword(sense.EntryId), sense.EntryId); // Field edits keep the "headword › senseLabel" subject so they read as // sense-level (the sense itself is what's constant, a field is what changed). - return (QualifiedSenseLabel(headword(sense.EntryId), sense), sense.EntryId); + return (QualifiedSenseLabel(Headword(sense.EntryId), sense), sense.EntryId); case nameof(ExampleSentence) when examples.TryGetValue(id, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): // Example edits keep the "headword › senseLabel" subject. // Unlike senses they don't have a "name" and a 3-level subject would be quite verbose. // Consumers can treat the target as part of the subject if they want - return (QualifiedSenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); + return (QualifiedSenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId); case nameof(ComplexFormComponent) when components.TryGetValue(id, out var component): - return (headword(component.ComplexFormEntryId), component.ComplexFormEntryId); + return (Headword(component.ComplexFormEntryId), component.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): @@ -252,30 +205,25 @@ ActivityChangeInfo Build(ChangeEntity change, Func headw case nameof(CustomView) when customViews.TryGetValue(id, out var view): return (NullIfEmpty(view.Name), null); case nameof(CommentThread) when commentThreads.TryGetValue(id, out var thread): - return CommentSubject(thread, headword); + return CommentSubject(thread); case nameof(UserComment) when userComments.TryGetValue(id, out var comment) && commentThreads.TryGetValue(comment.CommentThreadId, out var commentThread): - return CommentSubject(commentThread, headword); + return CommentSubject(commentThread); default: return (null, null); } } - (string? Subject, Guid? OwningEntryId) CommentSubject(CommentThread thread, Func headword) + (string? Subject, Guid? OwningEntryId) CommentSubject(CommentThread thread) => thread.SubjectType switch { - var subjectId = thread.SubjectId; - switch (thread.SubjectType) - { - case SubjectType.Entry: - return (headword(subjectId), subjectId); - case SubjectType.Sense when senses.TryGetValue(subjectId, out var sense): - return (QualifiedSenseLabel(headword(sense.EntryId), sense), sense.EntryId); - case SubjectType.ExampleSentence when examples.TryGetValue(subjectId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): - return (QualifiedSenseLabel(headword(exSense.EntryId), exSense), exSense.EntryId); - default: - return (null, null); - } - } + SubjectType.Entry => + (Headword(thread.SubjectId), thread.SubjectId), + SubjectType.Sense when senses.TryGetValue(thread.SubjectId, out var sense) => + (QualifiedSenseLabel(Headword(sense.EntryId), sense), sense.EntryId), + SubjectType.ExampleSentence when examples.TryGetValue(thread.SubjectId, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense) => + (QualifiedSenseLabel(Headword(exSense.EntryId), exSense), exSense.EntryId), + _ => (null, null) + }; // The item a change references (the part of speech set, the domain/publication/type removed). string? TargetLabel(ChangeEntity change) => change.Change switch @@ -310,6 +258,48 @@ CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId && userComments.TryGetValue(change.EntityId, out var comment) => CommentSnippet(comment.Text), _ => null }; + + string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry) : null; + + string? DisplayHeadword(Entry entry) + { + var headword = BestAlternative( + EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values, + writingSystemOrder, + v => v); + if (headword is null) return null; + return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword; + } + + // "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? QualifiedSenseLabel(string? headword, Sense sense) + { + var senseLabel = SenseLabel(sense); + if (headword is null) return string.IsNullOrEmpty(senseLabel) ? null : senseLabel; + return string.IsNullOrEmpty(senseLabel) ? headword : $"{headword} › {senseLabel}"; + } + + // Gloss + sense number if >1 sense (Mirrors FieldWorks) + // A gloss-less sense renders as "◌₂", which is a tad quircky. + // Rejected alternatives: '(2)' is too different from subscript. '_₂' risks making the subscript hard to read. '-₂' dash/emdash could read as formatting. + string SenseLabel(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 || siblings.Count <= 1) return glossText ?? ""; + return (glossText ?? "◌") + Subscript(index + 1); + } + + // The combination of code + name is the standard label for a semantic domain + string SemanticDomainLabel(SemanticDomain domain) => + Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code; + + string? Label(MultiString multiString) => BestAlternative(multiString.Values, writingSystemOrder, v => v); } // A projected load paired with its deleted-id recovery — the two always go together for a label load, so a @@ -324,6 +314,18 @@ private static async Task> LoadWithRecovery(ICrdtDbContex return loaded; } + // Objects the resolver labels only by name (possibilities like parts of speech, plus writing systems and + // custom views). Recovers deleted ids like the per-type loads, so a removed item can still be named. + 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(); + var result = loaded.ToDictionary(o => o.Id); + await RecoverDeleted(db, ids, result); + return result; + } + // Bulk loads snapshots for things not available in the projected tables (because they're deleted), // but that we still want to give a name to. private static async Task RecoverDeleted(ICrdtDbContext db, HashSet ids, Dictionary loaded) @@ -405,25 +407,6 @@ private static async Task> LoadExamples(ICrdtD return loaded.ToDictionary(e => e.Id); } - private static async Task> LoadUserComments(ICrdtDbContext db, HashSet ids) - { - if (ids.Count == 0) return []; - var loaded = await db.Set().Where(c => ids.Contains(c.Id)) - .Select(c => new UserComment { Id = c.Id, CommentThreadId = c.CommentThreadId, Text = c.Text }) - .ToListAsyncLinqToDB(); - return loaded.ToDictionary(c => c.Id); - } - - // Only the thread's subject (what it's attached to) is used to label the thread; not its comments. - private static async Task> LoadCommentThreads(ICrdtDbContext db, HashSet ids) - { - if (ids.Count == 0) return []; - var loaded = await db.Set().Where(t => ids.Contains(t.Id)) - .Select(t => new CommentThread { Id = t.Id, SubjectId = t.SubjectId, SubjectType = t.SubjectType }) - .ToListAsyncLinqToDB(); - return loaded.ToDictionary(t => t.Id); - } - private static async Task> LoadComponents(ICrdtDbContext db, HashSet ids) { if (ids.Count == 0) return []; @@ -447,29 +430,34 @@ private static async Task> LoadSemanticDomains( return loaded.ToDictionary(d => d.Id); } - // Objects the resolver labels only by name (possibilities like parts of speech, plus writing systems and - // custom views). Recovers deleted ids like the per-type loads, so a removed item can still be named. - private static async Task> LoadNamed(ICrdtDbContext db, HashSet ids, Expression> project) - where T : class, IObjectWithId + private static async Task> LoadUserComments(ICrdtDbContext db, HashSet ids) { if (ids.Count == 0) return []; - var loaded = await db.Set().Where(o => ids.Contains(o.Id)).Select(project).ToListAsyncLinqToDB(); - var result = loaded.ToDictionary(o => o.Id); - await RecoverDeleted(db, ids, result); - return result; + var loaded = await db.Set().Where(c => ids.Contains(c.Id)) + .Select(c => new UserComment { Id = c.Id, CommentThreadId = c.CommentThreadId, Text = c.Text }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(c => c.Id); } - // A plain (non-multi-string) display name, degraded to null when blank so it matches the null-when-empty - // convention the MultiString labels use (the frontend renders a placeholder rather than an empty subject). - private static string? NullIfEmpty(string? name) => string.IsNullOrWhiteSpace(name) ? null : name; + // Only the thread's subject (what it's attached to) is used to label the thread; not its comments. + private static async Task> LoadCommentThreads(ICrdtDbContext db, HashSet ids) + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(t => ids.Contains(t.Id)) + .Select(t => new CommentThread { Id = t.Id, SubjectId = t.SubjectId, SubjectType = t.SubjectType }) + .ToListAsyncLinqToDB(); + return loaded.ToDictionary(t => t.Id); + } - private static string Subscript(int number) + // Rank each writing system by the project's configured display order (GetWritingSystems already sorts by + // Order), so a label shows the alternative the editor lists first. A WsId shared by a vernacular and an + // analysis system keeps its first rank; cross-type interleaving doesn't really matter since a field holds one type. + private static Dictionary BuildWsOrder(WritingSystems writingSystems) { - Span buffer = stackalloc char[11]; // widest int32, e.g. "-2147483648" - number.TryFormat(buffer, out var charsWritten); - for (var i = 0; i < charsWritten; i++) - buffer[i] = buffer[i] == '-' ? '₋' : (char)(buffer[i] - '0' + '₀'); - return buffer[..charsWritten].ToString(); + var rank = new Dictionary(); + foreach (var ws in writingSystems.Vernacular.Concat(writingSystems.Analysis)) + rank.TryAdd(ws.WsId, rank.Count); + return rank; } /// iterates alternatives in WS-order rather than just WS's just in case there's only data for a "missing" WS @@ -489,15 +477,17 @@ private static string Subscript(int number) return null; } - // Rank each writing system by the project's configured display order (GetWritingSystems already sorts by - // Order), so a label shows the alternative the editor lists first. A WsId shared by a vernacular and an - // analysis system keeps its first rank; cross-type interleaving doesn't really matter since a field holds one type. - private static Dictionary BuildWsOrder(WritingSystems writingSystems) + // A plain (non-multi-string) display name, degraded to null when blank so it matches the null-when-empty + // convention the MultiString labels use (the frontend renders a placeholder rather than an empty subject). + private static string? NullIfEmpty(string? name) => string.IsNullOrWhiteSpace(name) ? null : name; + + private static string Subscript(int number) { - var rank = new Dictionary(); - foreach (var ws in writingSystems.Vernacular.Concat(writingSystems.Analysis)) - rank.TryAdd(ws.WsId, rank.Count); - return rank; + Span buffer = stackalloc char[11]; // widest int32, e.g. "-2147483648" + number.TryFormat(buffer, out var charsWritten); + for (var i = 0; i < charsWritten; i++) + buffer[i] = buffer[i] == '-' ? '₋' : (char)(buffer[i] - '0' + '₀'); + return buffer[..charsWritten].ToString(); } // Max grapheme clusters shown in an example-sentence snippet before it's truncated. @@ -510,11 +500,8 @@ private static Dictionary BuildWsOrder(WritingSystems writ internal static string? ExampleSnippet(RichMultiString sentence, IReadOnlyDictionary? wsOrder = null) { - return BestAlternative(sentence, wsOrder ?? ImmutableDictionary.Empty, richString => - { - var plainText = Truncate(richString?.GetPlainText(), TextSnippetBudget); - return string.IsNullOrWhiteSpace(plainText) ? null : plainText; - }); + return BestAlternative(sentence, wsOrder ?? ImmutableDictionary.Empty, + richString => Truncate(richString?.GetPlainText(), TextSnippetBudget)); } private static string? CommentSnippet(string? text) => Truncate(text, TextSnippetBudget); From 8e1397fd83d57086ff561447ff07d4a203143643 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 15:28:44 +0200 Subject: [PATCH 09/11] Restore the audio writing-system filter dropped in the reorder Co-Authored-By: Claude Fable 5 --- backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index 31c2041b68..c3700ddf8a 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -264,7 +264,7 @@ CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId string? DisplayHeadword(Entry entry) { var headword = BestAlternative( - EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values, + EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values.Where(kvp => !kvp.Key.IsAudio), writingSystemOrder, v => v); if (headword is null) return null; From f888250c72415c84c0ae06247cc00191be428c00 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 15:28:51 +0200 Subject: [PATCH 10/11] Pass the now-required changeInfo in HistoryView Co-Authored-By: Claude Fable 5 --- frontend/viewer/src/lib/history/HistoryView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index 7a26062199..51d0dd001f 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -103,7 +103,7 @@
{#if record} - + {/if}
From 327c4c10858c835d69f25dc96ba53cbf3be5ad21 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 22 Jul 2026 15:32:29 +0200 Subject: [PATCH 11/11] Simplify and standardize audio-ws filtering --- .../HistoryServiceActivityLabelPolicyTests.cs | 16 +++++----------- .../FwLite/LcmCrdt/ActivityChangeInfoResolver.cs | 3 ++- backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs | 3 ++- backend/FwLite/LcmCrdt/HistoryService.cs | 8 ++------ 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs index b74a04724b..f789d52de8 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs @@ -26,7 +26,7 @@ public async Task ProjectActivity_ChangeInfo_IdentifyingLabels_TrackCurrentState await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(entryId), Meta()); var deleted = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // Deletion doesn't undo the drift: the label recovered from the last snapshot is the renamed one. + // Deletion has no effect: the label recovered from the last snapshot is the renamed one. deleted.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateEntryChange) && a.Changes[0].Info.Subject == "jog"); } @@ -40,8 +40,6 @@ public async Task ProjectActivity_ChangeInfo_CommentSnippets_QuoteWrittenText_Ne await DataModel.AddChange(ClientId, new EditUserCommentChange(commentId, "third words", DateTimeOffset.UtcNow), Meta()); var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // Only the non-latest rows can catch a regression to the projection (the latest edit's payload and the - // current text agree). activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateUserCommentChange) && a.Changes[0].Info.Target == "original words"); activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is EditUserCommentChange) @@ -54,15 +52,11 @@ public async Task ProjectActivity_ChangeInfo_CommentSnippets_QuoteWrittenText_Ne public async Task ProjectActivity_ChangeInfo_SenseNumbers_ReflectCurrentPositions() { var entryId = await CreateEntry("run"); - await CreateSense(entryId, gloss: "first", order: 2.0); - await CreateSense(entryId, gloss: "early", order: 1.0); + await CreateSense(entryId, gloss: "first", order: 1.0); + await CreateSense(entryId, gloss: "early", order: 0.9); var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()); - // The sense added first (alone and unnumbered at the time) now reads as "first₂": the number locates the - // sense in the entry as it is today, at the cost of "added sense N" no longer meaning it was added at - // position N. Deliberate: the position as of the change isn't recoverable without replaying every - // sibling sense's snapshot per activity row, while the resolver labels a whole page from one batched - // query over the projected Senses table. - activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateSenseChange) && a.Changes[0].Info.Target == "first₂"); + activities.Should().Contain(a => a.Changes.Any(c => c.Entity.Change is CreateSenseChange) + && a.Changes[0].Info.Target == "first₂"); } } diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs index c3700ddf8a..871079f4de 100644 --- a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -264,7 +264,7 @@ CreateSenseChange or DeleteChange when senses.TryGetValue(change.EntityId string? DisplayHeadword(Entry entry) { var headword = BestAlternative( - EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values.Where(kvp => !kvp.Key.IsAudio), + EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values, writingSystemOrder, v => v); if (headword is null) return null; @@ -468,6 +468,7 @@ private static Dictionary BuildWsOrder(WritingSystems writ Func render) { foreach (var kvp in alternatives + .Where(kvp => !kvp.Key.IsAudio) .OrderBy(kvp => wsOrder.GetValueOrDefault(kvp.Key, int.MaxValue)) .ThenBy(kvp => kvp.Key.Code)) { diff --git a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs index e0babb6651..40d39b49e7 100644 --- a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs +++ b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs @@ -126,7 +126,8 @@ public static MultiString ComputeHeadwords(Entry entry, // Iterate all WS keys that have data, not just "current" vernacular WSs, // so we don't lose headwords for non-current or future writing systems. var wsIds = entry.CitationForm.Values.Keys - .Union(entry.LexemeForm.Values.Keys); + .Union(entry.LexemeForm.Values.Keys) + .Where(ws => !ws.IsAudio); foreach (var wsId in wsIds) { diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index 013d06e33f..a553f40034 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -165,12 +165,8 @@ from commit in commits.Skip(skip).Take(take) commit.ChangeEntities.ToList(), commit.Metadata); - // Independent reads on separate contexts (GetWritingSystems opens its own repo), so overlap them. - var activitiesTask = queryable.ToLinqToDB().ToArrayAsyncLinqToDB(); - var writingSystemsTask = miniLcmApi.GetWritingSystems(); - await Task.WhenAll(activitiesTask, writingSystemsTask); - var activities = await activitiesTask; - var writingSystems = await writingSystemsTask; + var activities = await queryable.ToLinqToDB().ToArrayAsyncLinqToDB(); + var writingSystems = await miniLcmApi.GetWritingSystems(); return await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities, writingSystems); }