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/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/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/ChangeTypesCodeGenerator.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs
new file mode 100644
index 0000000000..9b3dc10380
--- /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 ChangeType
+/// 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/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs
index 5f25af91d7..d4d4427eba 100644
--- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs
+++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs
@@ -184,6 +184,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(FwLiteConfig),
typeof(HistoryLineItem),
typeof(ProjectActivity),
+ typeof(ActivityChange),
+ typeof(ActivityChangeInfo),
typeof(ActivityAuthor),
typeof(ActivityChangeType),
typeof(ActivityQuery),
@@ -198,6 +200,12 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(HarmonyResource),
], exportBuilder => exportBuilder.WithPublicProperties());
+ builder.ExportAsClass().WithCodeGenerator();
+ builder.ExportAsInterface()
+ .WithProperty(t => t.Key, p => p.Type());
+ builder.ExportAsInterface()
+ .WithProperty(q => q.ChangeTypeKeys, p => p.Type());
+
builder.ExportAsEnum().UseString();
builder.ExportAsEnum().UseString();
builder.ExportAsEnum().UseString(false);
diff --git a/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverCoverageTests.cs
new file mode 100644
index 0000000000..5eabf40ec1
--- /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)
+ {
+ foreach (var change in activity.Changes)
+ {
+ 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;
+ }
+ }
+
+ 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..0d85298cfd
--- /dev/null
+++ b/backend/FwLite/LcmCrdt.Tests/ActivityChangeInfoResolverExampleSnippetTests.cs
@@ -0,0 +1,101 @@
+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…")]
+ // 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);
+ }
+
+ [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 6fa879794e..0074278483 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/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/HistoryServiceActivityLabelPolicyTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs
new file mode 100644
index 0000000000..f789d52de8
--- /dev/null
+++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityLabelPolicyTests.cs
@@ -0,0 +1,62 @@
+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.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 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");
+ }
+
+ [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());
+ 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]
+ public async Task ProjectActivity_ChangeInfo_SenseNumbers_ReflectCurrentPositions()
+ {
+ var entryId = await CreateEntry("run");
+ 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());
+ 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
new file mode 100644
index 0000000000..aa5b13419f
--- /dev/null
+++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivitySubjectTests.cs
@@ -0,0 +1,649 @@
+using LcmCrdt.Changes;
+using LcmCrdt.Changes.Comments;
+using LcmCrdt.Changes.Entries;
+using SIL.Harmony.Core;
+using SystemTextJsonPatch;
+
+namespace LcmCrdt.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()
+ {
+ 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.Changes.Count == 1 && a.Changes[0].Info.Subject == "plain");
+ activities.Should().Contain(a => a.Changes.Count == 1 && a.Changes[0].Info.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.Changes.Count == 1 && a.Changes[0].Info.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.Changes.Count == 1 && a.Changes[0].Info.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.Changes.Count == 1 && a.Changes[0].Info.Subject == "run" && a.Changes[0].Info.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 a dotted-circle placeholder for the subscript to attach to.
+ 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]
+ 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 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.Changes.Count == 1
+ && a.Changes[0].Info.OwningEntryId == entryId
+ && a.Changes[0].Info.Subject == null
+ && a.Changes[0].Info.OwningEntryHeadword == 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.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
+ // that label after deletion. ---
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_NamesPartOfSpeechSubject_EvenAfterDelete()
+ {
+ var id = Guid.NewGuid();
+ await DataModel.AddChange(ClientId, new CreatePartOfSpeechChange(id, new MultiString { ["en"] = "Verb" }), Meta());
+
+ 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.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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.Subject == "Verb");
+ }
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_NamesSemanticDomainSubject_EvenAfterDelete()
+ {
+ var id = Guid.NewGuid();
+ await DataModel.AddChange(ClientId, new CreateSemanticDomainChange(id, new MultiString { ["en"] = "Food" }, "5.2"), Meta());
+
+ var live = await Service.ProjectActivity(0, 100, new ActivityQuery());
+ // A domain shows as "code name".
+ 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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.Subject == "5.2 Food");
+ }
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_NamesPublicationSubject_EvenAfterDelete()
+ {
+ var id = Guid.NewGuid();
+ 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.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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.Subject == "Main Dictionary");
+ }
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_NamesComplexFormTypeSubject_EvenAfterDelete()
+ {
+ var id = Guid.NewGuid();
+ 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.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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.Subject == "Compound");
+ }
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_NamesWritingSystemSubject_EvenAfterDelete()
+ {
+ 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 owning entry.
+ 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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.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.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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.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());
+
+ 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. ---
+
+ [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.Changes.Any(c => c.Entity.Change is SIL.Harmony.Changes.DeleteChange)
+ && a.Changes[0].Info.Subject == "run"
+ && a.Changes[0].Info.OwningEntryHeadword == "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());
+
+ // 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.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),
+ // 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_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.Changes.Count == 1
+ && a.Changes[0].Info.Subject == "run › to run"
+ && a.Changes[0].Info.Target == "Noun");
+ }
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_AddSemanticDomain_NamesAddedDomain()
+ {
+ 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.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]
+ 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.Changes.Count == 1
+ && a.Changes[0].Info.Subject == "run › to run"
+ && a.Changes[0].Info.Target == "5.2 Food");
+ }
+
+ [Fact]
+ public async Task ProjectActivity_ChangeInfo_AddPublication_NamesAddedPublication()
+ {
+ 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.Changes.Any(c => c.Entity.Change is AddPublicationChange)
+ && a.Changes[0].Info.Subject == "run"
+ && a.Changes[0].Info.Target == "Main Dictionary");
+ }
+
+ [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.Changes.Count == 1
+ && a.Changes[0].Info.Subject == "run"
+ && a.Changes[0].Info.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.Changes.Any(c => c.Entity.Change is AddComplexFormTypeChange)
+ && a.Changes[0].Info.Subject == "blackbird"
+ && a.Changes[0].Info.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.Changes.Count == 1
+ && a.Changes[0].Info.Subject == "blackbird"
+ && a.Changes[0].Info.Target == "Compound");
+ }
+
+ [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.Changes.Count == 1
+ && a.Changes[0].Info.Subject == "blackbird"
+ && a.Changes[0].Info.OwningEntryId == complexFormId
+ && a.Changes[0].Info.Target == "bird");
+ }
+
+ [Fact]
+ 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 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());
+
+ // The delete still names the complex form (subject) and component (target) for orientation.
+ activities.Should().Contain(a => a.Changes.Count == 1
+ && a.Changes[0].Info.Subject == "blackbird"
+ && a.Changes[0].Info.Target == "bird");
+ }
+
+ [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 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.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]
+ public async Task ProjectActivity_ChangeInfo_DeletedExample_StillHasSentenceSnippet()
+ {
+ 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 daily") }
+ }, senseId), Meta());
+ await DataModel.AddChange(ClientId, new SIL.Harmony.Changes.DeleteChange(exampleId), Meta());
+
+ 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.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
+ // 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 owning entry.
+ 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]
+ public async Task ProjectActivity_ChangeInfo_UserComment_NamesCommentedSense_AndTextSnippet()
+ {
+ 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());
+
+ // The comment resolves to its sense (subject + owning entry) with the comment text as the target.
+ 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]
+ 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.Changes.Any(c => c.Entity.Change is CreateUserCommentChange)
+ && a.Changes[0].Info.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.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. ---
+
+ [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.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]
+ 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, 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.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]
+ 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.
+ // Pin to the reorder change: the add-component commit resolves to the same subject/target.
+ 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 dd53c90a69..9e8ac62767 100644
--- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs
+++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs
@@ -1,31 +1,10 @@
using LcmCrdt.Changes;
-using LcmCrdt.Utils;
-using Microsoft.EntityFrameworkCore;
-using MiniLcm.Tests.AutoFakerHelpers;
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 +39,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 +51,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 +64,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 +78,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 +90,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 +102,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,11 +114,11 @@ 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().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]
@@ -148,55 +127,23 @@ 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())
- .Should().Contain(a => a.ChangeTypes.Contains(nameof(CreatePartOfSpeechChange)));
+ (await Service.ProjectActivity(0, 100, new ActivityQuery()))
+ .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)])).ToArrayAsync();
+ var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: [nameof(CreateEntryChange), nameof(CreatePublicationChange)]));
- 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().HaveCountGreaterThanOrEqualTo(2); // the entry + publication commits, so the filter can't pass vacuously on an empty result
+ 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).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();
+ activity.Changes.Should().ContainSingle(c => c.Entity.Change is CreateEntryChange);
}
}
diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs
new file mode 100644
index 0000000000..5b06820bc7
--- /dev/null
+++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTestsBase.cs
@@ -0,0 +1,131 @@
+using LcmCrdt.Changes;
+using LcmCrdt.Changes.Comments;
+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 { ["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 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
+ ? 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..871079f4de
--- /dev/null
+++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs
@@ -0,0 +1,529 @@
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Linq.Expressions;
+using System.Text.RegularExpressions;
+using LcmCrdt.Changes;
+using LcmCrdt.Changes.Comments;
+using LcmCrdt.Changes.Entries;
+using LcmCrdt.Data;
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using SIL.Harmony.Changes;
+using SIL.Harmony.Core;
+using SIL.Harmony.Db;
+
+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.
+///
+/// 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 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.
+///
+/// Comment changes are the exception:
+/// - Every change carries the full comment text
+///
+/// Degradable: leaves null for types it doesn't resolve.
+///
+internal static class ActivityChangeInfoResolver
+{
+ public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities, WritingSystems writingSystems)
+ {
+ 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();
+ 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 (change.Change.EntityType.Name)
+ {
+ 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;
+ 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);
+ entryIds.Add(a.ComplexFormEntryId);
+ 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 LoadWithRecovery(db, userCommentIds, LoadUserComments);
+ foreach (var comment in userComments.Values) commentThreadIds.Add(comment.CommentThreadId);
+ var commentThreads = await LoadWithRecovery(db, commentThreadIds, LoadCommentThreads);
+ 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 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);
+ }
+
+ var examples = await LoadWithRecovery(db, exampleIds, LoadExamples);
+ foreach (var example in examples.Values) senseIds.Add(example.SenseId);
+ var senses = await LoadWithRecovery(db, senseIds, LoadSenses);
+ foreach (var sense in senses.Values) entryIds.Add(sense.EntryId);
+
+ // For looking up sense numbers
+ var sensesByEntry = await LoadSensesByEntry(db, [.. senses.Values.Select(s => s.EntryId)]);
+
+ var entries = await LoadWithRecovery(db, entryIds, LoadEntries);
+ var partsOfSpeech = await LoadNamed(db, partOfSpeechIds, p => new PartOfSpeech { Id = p.Id, Name = p.Name });
+ var semanticDomains = await LoadWithRecovery(db, semanticDomainIds, LoadSemanticDomains);
+ var publications = await LoadNamed(db, publicationIds, p => new Publication { Id = p.Id, Name = p.Name });
+ var complexFormTypes = await LoadNamed(db, complexFormTypeIds, c => new ComplexFormType { Id = c.Id, Name = c.Name });
+ var morphTypes = await LoadNamed(db, morphTypeIds, m => new MorphType { Id = m.Id, Kind = m.Kind, Name = m.Name });
+ 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 });
+ var customViews = await LoadNamed(db, customViewIds, v => new CustomView { Id = v.Id, Name = v.Name });
+
+ // 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());
+
+ // 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);
+
+ return [.. activities.Select(activity => new ProjectActivity(
+ activity.CommitId,
+ activity.Timestamp,
+ [.. activity.Changes.Select(change => new ActivityChange(change, Build(change)))],
+ activity.Metadata))];
+
+ ActivityChangeInfo Build(ChangeEntity change)
+ {
+ var info = ResolveReorder(change);
+ if (info is null)
+ {
+ var (subject, owningEntryId) = ResolveSubject(change);
+ info = new ActivityChangeInfo(subject, owningEntryId, TargetLabel(change));
+ }
+ 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) => change.Change switch
+ {
+ 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)
+ {
+ var id = change.EntityId;
+ switch (change.Change.EntityType.Name)
+ {
+ case nameof(Entry):
+ 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);
+ // 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);
+ 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);
+ 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);
+ case nameof(UserComment) when userComments.TryGetValue(id, out var comment)
+ && commentThreads.TryGetValue(comment.CommentThreadId, out var commentThread):
+ return CommentSubject(commentThread);
+ default:
+ return (null, null);
+ }
+ }
+
+ (string? Subject, Guid? OwningEntryId) CommentSubject(CommentThread thread) => thread.SubjectType switch
+ {
+ 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
+ {
+ SetPartOfSpeechChange { PartOfSpeechId: { } id } when partsOfSpeech.TryGetValue(id, out var pos) => Label(pos.Name),
+ 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),
+ // A deleted component link (e.g. "Removed component"): name the component from the recovered link.
+ _ when change.Change.EntityType == typeof(ComplexFormComponent)
+ && 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").
+ 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
+ // entity-type fallback below, which would show the current text.
+ CreateUserCommentChange c => CommentSnippet(c.Text),
+ 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),
+ // 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
+ };
+
+ 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
+ // deleted object can still be named. (LoadNamed recovers internally.)
+ 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;
+ }
+
+ // 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)
+ where T : class, IObjectWithId
+ {
+ 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 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;
+ }
+ }
+
+ // Per-type projected loads: only the columns the resolver uses for labeling
+
+ 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, Sentence = e.Sentence })
+ .ToListAsyncLinqToDB();
+ return loaded.ToDictionary(e => e.Id);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ // 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)
+ {
+ 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
+ /// the best (based on WS order) non-empty alternative or null
+ private static string? BestAlternative(
+ IEnumerable> alternatives,
+ IReadOnlyDictionary wsOrder,
+ 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))
+ {
+ var text = render(kvp.Value)?.Trim();
+ if (!string.IsNullOrEmpty(text)) return text;
+ }
+ return null;
+ }
+
+ // 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)
+ {
+ 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.
+ // 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);
+
+ internal static string? ExampleSnippet(RichMultiString sentence,
+ IReadOnlyDictionary? wsOrder = null)
+ {
+ return BestAlternative(sentence, wsOrder ?? ImmutableDictionary.Empty,
+ richString => Truncate(richString?.GetPlainText(), TextSnippetBudget));
+ }
+
+ 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/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 3c002a4c18..a553f40034 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;
@@ -36,28 +37,46 @@ 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 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.
+///
+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().ToArray();
}
+/// 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,
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(
@@ -105,7 +124,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()
@@ -131,7 +150,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();
@@ -139,14 +160,14 @@ public async IAsyncEnumerable ProjectActivity(int skip = 0, int
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);
- await foreach (var projectActivity in queryable.ToLinqToDB().AsAsyncEnumerable())
- {
- yield return projectActivity;
- }
+
+ var activities = await queryable.ToLinqToDB().ToArrayAsyncLinqToDB();
+ var writingSystems = await miniLcmApi.GetWritingSystems();
+ return await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities, writingSystems);
}
private static IQueryable ApplyActivityFilters(IQueryable commits, ActivityQuery query)
@@ -220,18 +241,21 @@ 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",
+ 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;
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))
@@ -299,23 +323,37 @@ 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