diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs
new file mode 100644
index 0000000000..ea7d89dcdd
--- /dev/null
+++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs
@@ -0,0 +1,45 @@
+using System.Reflection;
+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 IPolyType.TypeName
+/// (the 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(GetChangeTypeName)
+ .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!;
+ }
+
+ private static string GetChangeTypeName(Type changeType)
+ {
+ var typeNameProperty = changeType.GetProperty("TypeName",
+ BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
+ // Mirrors Harmony's own discriminator logic, so the generated list stays in sync with the runtime $type:
+ // generic/shared changes (jsonPatch:, delete:, SetOrderChange:) define a custom static TypeName, while a
+ // dedicated change class (e.g. SetPartOfSpeechChange) has none and serializes under its CLR type name.
+ // The fallback to changeType.Name is therefore intentional, not a silent mismatch.
+ return typeNameProperty?.GetValue(null) as string ?? changeType.Name;
+ }
+}
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/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs
index dd53c90a69..503f70a63c 100644
--- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs
@@ -1,9 +1,11 @@
using LcmCrdt.Changes;
+using LcmCrdt.Changes.Entries;
using LcmCrdt.Utils;
using Microsoft.EntityFrameworkCore;
using MiniLcm.Tests.AutoFakerHelpers;
using SIL.Harmony.Core;
using Soenneker.Utils.AutoBogus;
+using SystemTextJsonPatch;
namespace LcmCrdt.Tests;
@@ -153,6 +155,7 @@ public async Task ProjectActivity_ChangeTypeKeys_FiltersMultipleTypes()
var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange), nameof(CreatePublicationChange)])).ToArrayAsync();
+ 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)));
}
@@ -167,6 +170,438 @@ public async Task ProjectActivity_IncludesChangeTypes()
activity.ChangeTypes.Should().Contain("CreateEntryChange");
}
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_AddsHomographSubscriptToSubject_OnlyWhenAssigned()
+ {
+ await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry
+ {
+ Id = Guid.NewGuid(),
+ LexemeForm = new MultiString { ["en"] = "plain" }
+ }), new CommitMetadata { AuthorName = "A", AuthorId = "a" });
+ await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry
+ {
+ Id = Guid.NewGuid(),
+ LexemeForm = new MultiString { ["en"] = "homograph" },
+ HomographNumber = 2
+ }), new CommitMetadata { AuthorName = "A", AuthorId = "a" });
+
+ var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ // 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_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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ 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()).ToArrayAsync();
+
+ // 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()).ToArrayAsync();
+
+ // Reordering a component names the complex form as subject and the moved component as target.
+ activities.Should().Contain(a => a.ChangeInfo.Count == 1
+ && a.ChangeInfo[0].Subject == "blackbird"
+ && a.ChangeInfo[0].RootEntryId == complexFormId
+ && a.ChangeInfo[0].Target == "bird");
+ }
+
+ private static CommitMetadata Meta() => new() { AuthorName = "A", AuthorId = "a" };
+
+ private async Task CreateEntry(string headword)
+ {
+ var entryId = Guid.NewGuid();
+ await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry
+ {
+ Id = entryId,
+ LexemeForm = new MultiString { ["en"] = headword }
+ }), Meta());
+ return entryId;
+ }
+
+ private async Task CreateSense(Guid entryId, string? gloss, double order, IList? semanticDomains = null)
+ {
+ var senseId = Guid.NewGuid();
+ var sense = new Sense
+ {
+ Id = senseId,
+ Order = order,
+ Gloss = gloss is null ? new MultiString() : new MultiString { ["en"] = gloss },
+ SemanticDomains = semanticDomains ?? []
+ };
+ await DataModel.AddChange(ClientId, new CreateSenseChange(sense, entryId), Meta());
+ return senseId;
+ }
+
+ private async Task AddComponent(Guid complexFormId, Guid componentId)
+ {
+ var component = ComplexFormComponent.FromEntries(
+ new Entry { Id = complexFormId },
+ new Entry { Id = componentId });
+ await DataModel.AddChange(ClientId, new AddEntryComponentChange(component), Meta());
+ return component;
+ }
+
private async Task AddEntryCommit(CommitMetadata metadata, string? headword = null)
{
var entry = headword is null
diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs
new file mode 100644
index 0000000000..05659ac8f5
--- /dev/null
+++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs
@@ -0,0 +1,390 @@
+using System.Linq.Expressions;
+using LcmCrdt.Changes;
+using LcmCrdt.Changes.Entries;
+using LcmCrdt.Data;
+using LinqToDB;
+using LinqToDB.Async;
+using LinqToDB.EntityFrameworkCore;
+using MiniLcm.Models;
+using SIL.Harmony.Changes;
+using SIL.Harmony.Core;
+using SIL.Harmony.Db;
+
+namespace LcmCrdt;
+
+///
+/// Batch-resolves a human label (and the root entry) for each change in a page of activity, so summaries
+/// can name the entry/sense/vocab object a change is about without a per-row lookup. Degradable: leaves
+/// null for types it doesn't resolve (the frontend falls back to a type label).
+/// Reads the projected snapshot tables, so labels reflect the current state; objects missing from the
+/// projection (deleted) are recovered from their latest snapshot so a delete can still name its subject.
+/// The display headword is the best non-audio alternative across writing systems with morph-type markers
+/// applied (e.g. "-ness" for a suffix); it's null when there's no displayable headword (all empty/audio-only)
+/// or the entry is missing entirely, and the frontend renders a translatable placeholder in that case.
+///
+internal static class ActivityChangeInfoResolver
+{
+ public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities)
+ {
+ var entryIds = new HashSet();
+ var senseIds = new HashSet();
+ var exampleIds = new HashSet();
+ var componentIds = new HashSet();
+ var partOfSpeechIds = new HashSet();
+ var semanticDomainIds = new HashSet();
+ var publicationIds = new HashSet();
+ var complexFormTypeIds = new HashSet();
+ var morphTypeIds = new HashSet();
+
+ foreach (var change in activities.SelectMany(a => a.Changes))
+ {
+ switch (BucketFor(change.Change.EntityType))
+ {
+ case nameof(Entry): entryIds.Add(change.EntityId); break;
+ case nameof(Sense): senseIds.Add(change.EntityId); break;
+ case nameof(ExampleSentence): exampleIds.Add(change.EntityId); break;
+ case nameof(ComplexFormComponent): componentIds.Add(change.EntityId); break;
+ case nameof(PartOfSpeech): partOfSpeechIds.Add(change.EntityId); break;
+ case nameof(SemanticDomain): semanticDomainIds.Add(change.EntityId); break;
+ case nameof(Publication): publicationIds.Add(change.EntityId); break;
+ case nameof(ComplexFormType): complexFormTypeIds.Add(change.EntityId); break;
+ case nameof(MorphType): morphTypeIds.Add(change.EntityId); break;
+ }
+
+ // Some changes name a referenced item only by id; fold those ids into the same batch-loads so we can label them.
+ switch (change.Change)
+ {
+ case SetPartOfSpeechChange { PartOfSpeechId: { } posId }: partOfSpeechIds.Add(posId); break;
+ case RemoveSemanticDomainChange r: semanticDomainIds.Add(r.SemanticDomainId); break;
+ case RemovePublicationChange r: publicationIds.Add(r.PublicationId); break;
+ case RemoveComplexFormTypeChange r: complexFormTypeIds.Add(r.ComplexFormTypeId); break;
+ case AddEntryComponentChange a:
+ entryIds.Add(a.ComponentEntryId);
+ // Also load the complex-form entry so we can name the subject when the CFC has been deleted
+ // (soft-deleted CFCs are absent from the projection dict, so the ComplexFormComponent case
+ // below falls back to this change's ComplexFormEntryId).
+ entryIds.Add(a.ComplexFormEntryId);
+ break;
+ case SetComplexFormComponentChange { ComponentEntryId: { } cid }: entryIds.Add(cid); break;
+ }
+ }
+
+ // Walk down to the root entry: component → entry, example → sense → entry. Load both ends of a component link so we can name either.
+ var components = await LoadComponents(db, componentIds);
+ foreach (var component in components.Values)
+ {
+ entryIds.Add(component.ComplexFormEntryId);
+ entryIds.Add(component.ComponentEntryId);
+ }
+
+ // A deleted component link is absent from the projection above, so "Removed component" would have no
+ // headwords. Recover its endpoints from its create change (AddEntryComponentChange carries both entry
+ // ids) — enough to name the complex form and component for basic orientation.
+ var deletedComponentIds = componentIds.Where(id => !components.ContainsKey(id)).ToHashSet();
+ var deletedComponentEndpoints = await LoadComponentEndpointsFromCreate(db, deletedComponentIds);
+ foreach (var (complexFormEntryId, componentEntryId) in deletedComponentEndpoints.Values)
+ {
+ entryIds.Add(complexFormEntryId);
+ entryIds.Add(componentEntryId);
+ }
+ var examples = await LoadExamples(db, exampleIds);
+ await RecoverDeleted(db, exampleIds, examples);
+ foreach (var example in examples.Values) senseIds.Add(example.SenseId);
+ var senses = await LoadSenses(db, senseIds);
+ await RecoverDeleted(db, senseIds, senses);
+ foreach (var sense in senses.Values) entryIds.Add(sense.EntryId);
+
+ // Sibling senses of every affected sense's entry, so a sense's subject can carry its 1-based position
+ // (and detect duplicate glosses) without a per-sense query. Ordered by Order to match the editor.
+ var sensesByEntry = await LoadSensesByEntry(db, senses.Values.Select(s => s.EntryId).ToHashSet());
+
+ var entries = await LoadEntries(db, entryIds);
+ await RecoverDeleted(db, entryIds, entries);
+ var partsOfSpeech = await LoadNamed(db, partOfSpeechIds, p => new PartOfSpeech { Id = p.Id, Name = p.Name });
+ await RecoverDeleted(db, partOfSpeechIds, partsOfSpeech);
+ var semanticDomains = await LoadSemanticDomains(db, semanticDomainIds);
+ await RecoverDeleted(db, semanticDomainIds, semanticDomains);
+ var publications = await LoadNamed(db, publicationIds, p => new Publication { Id = p.Id, Name = p.Name });
+ await RecoverDeleted(db, publicationIds, publications);
+ var complexFormTypes = await LoadNamed(db, complexFormTypeIds, c => new ComplexFormType { Id = c.Id, Name = c.Name });
+ await RecoverDeleted(db, complexFormTypeIds, complexFormTypes);
+ var morphTypes = await LoadNamed(db, morphTypeIds, m => new MorphType { Id = m.Id, Kind = m.Kind, Name = m.Name });
+ await RecoverDeleted(db, morphTypeIds, morphTypes);
+
+ // Markers (e.g. suffix "-") for the display headword, keyed by morph-type kind; first wins on duplicates.
+ // Only needed to render an entry headword, so skip the load entirely when no entry is being resolved.
+ var morphLookup = entryIds.Count == 0
+ ? new Dictionary()
+ : (await db.Set().Select(m => new MorphType { Kind = m.Kind, Prefix = m.Prefix, Postfix = m.Postfix }).ToListAsyncLinqToDB())
+ .GroupBy(m => m.Kind)
+ .ToDictionary(g => g.Key, g => g.First());
+
+ string? Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? DisplayHeadword(entry, morphLookup) : null;
+
+ // The distinguishing label for a sense within its entry. FieldWorks numbers senses positionally and
+ // shows the number when an entry has more than one sense — independent of the gloss (unlike homograph
+ // numbers, which key off identical headwords). Rendered like a homograph number: >1 sense →
+ // "{gloss}{subscript number}"; when the gloss is empty, "({number})" — parenthesized so a bare
+ // position can't be mistaken for a gloss that IS a digit (a bare subscript would have nothing to
+ // attach to, and this string is data, so a translatable "no gloss" placeholder can't live here).
+ // A lone sense → its gloss, or "" when it has none (nothing to distinguish).
+ string SenseGlossPart(Sense sense)
+ {
+ var siblings = sensesByEntry.GetValueOrDefault(sense.EntryId) ?? [];
+ var index = siblings.FindIndex(s => s.Id == sense.Id);
+ var glossText = Label(sense.Gloss);
+ // A deleted sense (recovered from its snapshot) is absent from the live sibling list — it has no
+ // meaningful position, so no number.
+ if (index < 0) return glossText ?? "";
+ var number = index + 1;
+ var multiple = siblings.Count > 1;
+ if (!string.IsNullOrEmpty(glossText)) return multiple ? glossText + Subscript(number) : glossText;
+ return multiple ? $"({number})" : "";
+ }
+
+ // "headword › senseLabel". Degrades to just the headword when the sense has nothing to distinguish it
+ // (a lone, gloss-less sense), and to "sense" when there's neither a headword nor a distinguishing label.
+ string SenseLabel(string? headword, Sense sense)
+ {
+ var glossPart = SenseGlossPart(sense);
+ if (headword is null) return string.IsNullOrEmpty(glossPart) ? "sense" : glossPart;
+ return string.IsNullOrEmpty(glossPart) ? headword : $"{headword} › {glossPart}";
+ }
+
+ foreach (var activity in activities)
+ {
+ activity.ChangeInfo = activity.Changes
+ .Select(change => Build(change, Headword))
+ .ToList();
+ }
+
+ 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.
+ private static async Task RecoverDeleted(ICrdtDbContext db, HashSet ids, Dictionary loaded)
+ where T : class, IObjectWithId
+ {
+ var missing = ids.Where(id => !loaded.ContainsKey(id)).ToHashSet();
+ if (missing.Count == 0) return;
+ var snapshots = await (
+ from commit in db.Commits.DefaultOrder()
+ from snapshot in db.Snapshots.InnerJoin(s => s.CommitId == commit.Id)
+ where missing.Contains(snapshot.EntityId)
+ select snapshot
+ ).ToListAsyncLinqToDB();
+ foreach (var group in snapshots.GroupBy(s => s.EntityId))
+ {
+ if (group.Last().Entity.DbObject is T entity) loaded[group.Key] = 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))
+ .ToListAsyncLinqToDB();
+ return changeEntities
+ .Select(ce => ce.Change)
+ .OfType()
+ .GroupBy(c => c.EntityId)
+ .ToDictionary(g => g.Key, g => (g.First().ComplexFormEntryId, g.First().ComponentEntryId));
+ }
+
+ private static async Task> LoadComponents(ICrdtDbContext db, HashSet ids)
+ {
+ if (ids.Count == 0) return [];
+ var loaded = await db.Set().Where(c => ids.Contains(c.Id))
+ .Select(c => new ComplexFormComponent
+ {
+ Id = c.Id,
+ ComplexFormEntryId = c.ComplexFormEntryId,
+ ComponentEntryId = c.ComponentEntryId,
+ })
+ .ToListAsyncLinqToDB();
+ return loaded.ToDictionary(c => c.Id);
+ }
+
+ private static async Task> LoadSemanticDomains(ICrdtDbContext db, HashSet ids)
+ {
+ if (ids.Count == 0) return [];
+ var loaded = await db.Set().Where(d => ids.Contains(d.Id))
+ .Select(d => new SemanticDomain { Id = d.Id, Code = d.Code, Name = d.Name })
+ .ToListAsyncLinqToDB();
+ return loaded.ToDictionary(d => d.Id);
+ }
+
+ // Vocab objects the resolver only labels by name (part of speech, publication, complex-form type, morph type).
+ private static async Task> LoadNamed(ICrdtDbContext db, HashSet ids, Expression> project)
+ where T : class, IObjectWithId
+ {
+ if (ids.Count == 0) return [];
+ var loaded = await db.Set().Where(o => ids.Contains(o.Id)).Select(project).ToListAsyncLinqToDB();
+ return loaded.ToDictionary(o => o.Id);
+ }
+
+ // Order by writing-system code so a multi-writing-system name resolves to the same alternative every time, matching Entry.Headword().
+ private static string? Label(MultiString multiString) =>
+ multiString.Values.OrderBy(kvp => kvp.Key.Code).Select(kvp => kvp.Value?.Trim()).FirstOrDefault(text => !string.IsNullOrEmpty(text));
+
+ // Best non-audio alternative across writing systems (ordered by code, matching Label()) with morph-type markers
+ // applied (e.g. "-ness" for a suffix); null when there's no displayable text. FieldWorks distinguishes same-spelled
+ // entries by a homograph number shown as a subscript, assigned (> 0) only on a collision.
+ private static string? DisplayHeadword(Entry entry, IReadOnlyDictionary morphLookup)
+ {
+ var headword = EntryQueryHelpers.ComputeHeadwords(entry, morphLookup).Values
+ .Where(kvp => !kvp.Key.IsAudio)
+ .OrderBy(kvp => kvp.Key.Code)
+ .Select(kvp => kvp.Value?.Trim())
+ .FirstOrDefault(text => !string.IsNullOrEmpty(text));
+ if (string.IsNullOrEmpty(headword)) return null;
+ return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword;
+ }
+
+ private static string Subscript(int number) =>
+ new(number.ToString().Select(digit => (char)('₀' + (digit - '0'))).ToArray());
+
+ // The app shows a domain as "code name" (e.g. "5.2 Food"); the code alone is too cryptic to identify it.
+ private static string SemanticDomainLabel(SemanticDomain domain) =>
+ Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code;
+}
diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs
index 3c002a4c18..fac82b2474 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; set; } = [];
}
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(
@@ -143,7 +156,15 @@ from commit in commits.Skip(skip).Take(take)
commit.HybridDateTime.DateTime,
commit.ChangeEntities.ToList(),
commit.Metadata);
+ // Materialize the whole page before resolving: ActivityChangeInfoResolver batch-loads labels across all
+ // changes in the page at once, so this enumerates the page rather than streaming row-by-row.
+ var activities = new List();
await foreach (var projectActivity in queryable.ToLinqToDB().AsAsyncEnumerable())
+ {
+ activities.Add(projectActivity);
+ }
+ await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities);
+ foreach (var projectActivity in activities)
{
yield return projectActivity;
}
@@ -301,21 +322,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