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(commitId, change.EntityId) as IObjectWithId; + // These three branches are independent read-only lookups that each open their own + // DbContext (via the repository factories), so run them concurrently. + var snapshotTask = ResolveSnapshot(async () => await dataModel.GetAtCommit(commitId, change.EntityId)); + var previousSnapshotTask = ResolveSnapshot(() => dataModel.GetBeforeCommit(commitId, change.EntityId)); + var affectedEntriesTask = GetAffectedEntryIds(change) + .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) + .ToArrayAsync() + .AsTask(); + + await Task.WhenAll(snapshotTask, previousSnapshotTask, affectedEntriesTask); + + return new ChangeContext(change, await snapshotTask, await previousSnapshotTask, await affectedEntriesTask); + } + // Some entity types like RemoteResource don't implement IObjectWithId, so cast safely. + private async Task ResolveSnapshot(Func> fetch) + { + var snapshot = await fetch() as IObjectWithId; + await ResolveSensePartOfSpeech(snapshot); + return snapshot; + } + + // Older sense snapshots didn't store the part-of-speech object, only its id; resolve it so previews can show a label. + private async Task ResolveSensePartOfSpeech(IObjectWithId? snapshot) + { if (snapshot is Sense sense && sense.PartOfSpeechId != sense.PartOfSpeech?.Id) { - // previously we didn't update the part of speech object on sense snapshots - // we do now, so we patch old snapshots sense.PartOfSpeech = sense.PartOfSpeechId.HasValue ? await dataModel.GetLatest(sense.PartOfSpeechId.Value) : null; } - - var affectedEntries = await GetAffectedEntryIds(change) - .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) - .ToArrayAsync(); - - return new ChangeContext(change, snapshot, affectedEntries); } private async Task GetCurrentOrLatestEntry(Guid entryId) @@ -361,14 +399,14 @@ private async IAsyncEnumerable GetAffectedEntryIds(ChangeEntity c } } - public static string ChangesNameHelper(List> changeEntities) + public static string ChangesNameHelper(IReadOnlyList changes) { - return changeEntities switch + return changes switch { { Count: 0 } => "No changes", - { Count: 1 } => ChangeNameHelper(changeEntities[0].Change), - { Count: > 10 } => $"{changeEntities.Count} changes", - { Count: var count } => $"{ChangeNameHelper(changeEntities[0].Change)} (+{count - 1} other change{(count > 2 ? "s" : "")})", + { Count: 1 } => ChangeNameHelper(changes[0].Entity.Change), + { Count: > 10 } => $"{changes.Count} changes", + { Count: var count } => $"{ChangeNameHelper(changes[0].Entity.Change)} (+{count - 1} other change{(count > 2 ? "s" : "")})", }; } diff --git a/backend/FwLite/MiniLcm/Models/ComplexFormType.cs b/backend/FwLite/MiniLcm/Models/ComplexFormType.cs index df53a9b72c..3dbb3c5817 100644 --- a/backend/FwLite/MiniLcm/Models/ComplexFormType.cs +++ b/backend/FwLite/MiniLcm/Models/ComplexFormType.cs @@ -1,7 +1,7 @@ namespace MiniLcm.Models; //todo support an order for the complex form types, might be here, or on the entry -public record ComplexFormType : IObjectWithId +public record ComplexFormType : IPossibility, IObjectWithId { public virtual Guid Id { get; set; } public virtual required MultiString Name { get; set; } diff --git a/backend/FwLite/MiniLcm/Models/MorphType.cs b/backend/FwLite/MiniLcm/Models/MorphType.cs index 281e62c96e..0ce8509332 100644 --- a/backend/FwLite/MiniLcm/Models/MorphType.cs +++ b/backend/FwLite/MiniLcm/Models/MorphType.cs @@ -27,7 +27,7 @@ public enum MorphTypeKind DiscontiguousPhrase, } -public class MorphType : IObjectWithId +public class MorphType : IPossibility, IObjectWithId { public virtual Guid Id { get; set; } public virtual required MorphTypeKind Kind { get; set; } diff --git a/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs b/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs index 1390973576..92b77fd4b9 100644 --- a/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs +++ b/backend/FwLite/MiniLcm/Models/PartOfSpeech.cs @@ -1,6 +1,6 @@ namespace MiniLcm.Models; -public class PartOfSpeech : IObjectWithId +public class PartOfSpeech : IPossibility, IObjectWithId { public Guid Id { get; set; } public virtual MultiString Name { get; set; } = new(); diff --git a/backend/FwLite/MiniLcm/Models/SemanticDomain.cs b/backend/FwLite/MiniLcm/Models/SemanticDomain.cs index 15437b9bb1..f300d8c0da 100644 --- a/backend/FwLite/MiniLcm/Models/SemanticDomain.cs +++ b/backend/FwLite/MiniLcm/Models/SemanticDomain.cs @@ -1,6 +1,6 @@ namespace MiniLcm.Models; -public class SemanticDomain : IObjectWithId +public class SemanticDomain : IPossibility, IObjectWithId { public virtual Guid Id { get; set; } public virtual MultiString Name { get; set; } = new(); diff --git a/frontend/viewer/src/lib/activity/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 3dc4673c04..106b591562 100644 --- a/frontend/viewer/src/lib/activity/ActivityItem.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItem.svelte @@ -49,8 +49,8 @@ const historyService = useHistoryService(); let openHistoryId = $state() - const changes = $derived(!historyService.loaded ? undefined : activity.changes.map(change => { - return new ChangeWithLazyContext(change, activity, () => historyService.loadChangeContext(activity.commitId, change.index)); + const changes = $derived(!historyService.loaded ? undefined : activity.changes.map(({entity}) => { + return new ChangeWithLazyContext(entity, activity, () => historyService.loadChangeContext(activity.commitId, entity.index)); })); diff --git a/frontend/viewer/src/lib/activity/utils.ts b/frontend/viewer/src/lib/activity/utils.ts index d27f8fc5fc..446eb496ec 100644 --- a/frontend/viewer/src/lib/activity/utils.ts +++ b/frontend/viewer/src/lib/activity/utils.ts @@ -1,4 +1,5 @@ import {ActivitySort, type IActivityAuthor, type IActivityQuery, type IProjectActivity} from '$lib/dotnet-types'; +import type {ChangeType} from '$lib/dotnet-types/generated-types/LcmCrdt/ChangeType'; import {gt} from 'svelte-i18n-lingui'; export const ALL_AUTHORS = '__all__'; @@ -48,7 +49,7 @@ export function resolveFilterKeys(selected: MultiFilterSelection, allKeys: strin export function toServerQuery(filters: ActivityFilters): IActivityQuery { return { authorFilterKeys: filters.authorFilterKeys === 'all' ? undefined : filters.authorFilterKeys, - changeTypeKeys: filters.changeTypeFilterKeys === 'all' ? undefined : filters.changeTypeFilterKeys, + changeTypeKeys: filters.changeTypeFilterKeys === 'all' ? undefined : filters.changeTypeFilterKeys as ChangeType[], sort: filters.sort, }; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts new file mode 100644 index 0000000000..a842b06404 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeType.ts @@ -0,0 +1,141 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export type ChangeType = + | 'AddComplexFormTypeChange' + | 'AddEntryComponentChange' + | 'AddPublicationChange' + | 'AddSemanticDomainChange' + | 'AddTranslationChange' + | 'CreateCommentThreadChange' + | 'CreateComplexFormType' + | 'CreateCustomViewChange' + | 'CreateEntryChange' + | 'CreateExampleSentenceChange' + | 'CreateMorphTypeChange' + | 'CreatePartOfSpeechChange' + | 'CreatePublicationChange' + | 'CreateSemanticDomainChange' + | 'CreateSenseChange' + | 'CreateSensePictureChange' + | 'CreateUserCommentChange' + | 'CreateWritingSystemChange' + | 'EditCustomViewChange' + | 'EditUserCommentChange' + | 'MoveSenseToEntryChange' + | 'RemoveComplexFormTypeChange' + | 'RemovePublicationChange' + | 'RemoveSemanticDomainChange' + | 'RemoveSensePictureChange' + | 'RemoveTranslationChange' + | 'ReorderSensePictureChange' + | 'ReplacePublicationChange' + | 'ReplaceSemanticDomainChange' + | 'SetCommentThreadStatusChange' + | 'SetFirstTranslationIdChange' + | 'SetMainPublicationChange' + | 'SetOrderChange:ComplexFormComponent' + | 'SetOrderChange:ExampleSentence' + | 'SetOrderChange:Sense' + | 'SetOrderChange:WritingSystem' + | 'SetPartOfSpeechChange' + | 'UpdateSensePictureChange' + | 'UpdateTranslationChange' + | 'create:pendingUpload' + | 'create:remote-resource' + | 'delete:CommentThread' + | 'delete:ComplexFormComponent' + | 'delete:ComplexFormType' + | 'delete:CustomView' + | 'delete:Entry' + | 'delete:ExampleSentence' + | 'delete:PartOfSpeech' + | 'delete:Publication' + | 'delete:RemoteResource' + | 'delete:SemanticDomain' + | 'delete:Sense' + | 'delete:UserComment' + | 'delete:WritingSystem' + | 'jsonPatch:ComplexFormType' + | 'jsonPatch:Entry' + | 'jsonPatch:ExampleSentence' + | 'jsonPatch:MorphType' + | 'jsonPatch:PartOfSpeech' + | 'jsonPatch:Publication' + | 'jsonPatch:SemanticDomain' + | 'jsonPatch:Sense' + | 'jsonPatch:WritingSystem' + | 'set:remote-resource-metadata' + | 'uploaded:RemoteResource'; + +export const knownChangeTypes = [ + 'AddComplexFormTypeChange', + 'AddEntryComponentChange', + 'AddPublicationChange', + 'AddSemanticDomainChange', + 'AddTranslationChange', + 'CreateCommentThreadChange', + 'CreateComplexFormType', + 'CreateCustomViewChange', + 'CreateEntryChange', + 'CreateExampleSentenceChange', + 'CreateMorphTypeChange', + 'CreatePartOfSpeechChange', + 'CreatePublicationChange', + 'CreateSemanticDomainChange', + 'CreateSenseChange', + 'CreateSensePictureChange', + 'CreateUserCommentChange', + 'CreateWritingSystemChange', + 'EditCustomViewChange', + 'EditUserCommentChange', + 'MoveSenseToEntryChange', + 'RemoveComplexFormTypeChange', + 'RemovePublicationChange', + 'RemoveSemanticDomainChange', + 'RemoveSensePictureChange', + 'RemoveTranslationChange', + 'ReorderSensePictureChange', + 'ReplacePublicationChange', + 'ReplaceSemanticDomainChange', + 'SetCommentThreadStatusChange', + 'SetFirstTranslationIdChange', + 'SetMainPublicationChange', + 'SetOrderChange:ComplexFormComponent', + 'SetOrderChange:ExampleSentence', + 'SetOrderChange:Sense', + 'SetOrderChange:WritingSystem', + 'SetPartOfSpeechChange', + 'UpdateSensePictureChange', + 'UpdateTranslationChange', + 'create:pendingUpload', + 'create:remote-resource', + 'delete:CommentThread', + 'delete:ComplexFormComponent', + 'delete:ComplexFormType', + 'delete:CustomView', + 'delete:Entry', + 'delete:ExampleSentence', + 'delete:PartOfSpeech', + 'delete:Publication', + 'delete:RemoteResource', + 'delete:SemanticDomain', + 'delete:Sense', + 'delete:UserComment', + 'delete:WritingSystem', + 'jsonPatch:ComplexFormType', + 'jsonPatch:Entry', + 'jsonPatch:ExampleSentence', + 'jsonPatch:MorphType', + 'jsonPatch:PartOfSpeech', + 'jsonPatch:Publication', + 'jsonPatch:SemanticDomain', + 'jsonPatch:Sense', + 'jsonPatch:WritingSystem', + 'set:remote-resource-metadata', + 'uploaded:RemoteResource', +] as const satisfies readonly ChangeType[]; + +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts new file mode 100644 index 0000000000..75fc5e5cce --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChange.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {IChangeEntity} from '../SIL/Harmony/Core/IChangeEntity'; +import type {IActivityChangeInfo} from './IActivityChangeInfo'; + +export interface IActivityChange +{ + entity: IChangeEntity; + info: IActivityChangeInfo; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts new file mode 100644 index 0000000000..420182409d --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +export interface IActivityChangeInfo +{ + subject?: string; + owningEntryId?: string; + target?: string; + owningEntryHeadword?: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts index 85e76c74ff..e63464081e 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts @@ -3,9 +3,11 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {ChangeType} from './ChangeType'; + export interface IActivityChangeType { - key: string; + key: ChangeType; label: string; commitCount: number; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts index 686a24fa44..49a0553b14 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts @@ -3,12 +3,13 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. +import type {ChangeType} from './ChangeType'; import type {ActivitySort} from './ActivitySort'; export interface IActivityQuery { authorFilterKeys?: string[]; - changeTypeKeys?: string[]; + changeTypeKeys?: ChangeType[]; sort: ActivitySort; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts index f93756ea9e..5088ebaa89 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IChangeContext.ts @@ -12,6 +12,7 @@ export interface IChangeContext changeIndex: number; changeName: string; snapshot?: IObjectWithId; + previousSnapshot?: IObjectWithId; affectedEntries: IEntry[]; entityType: string; } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts index ad0681ad65..6cf731f56f 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts @@ -3,16 +3,15 @@ // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. -import type {IChangeEntity} from '../SIL/Harmony/Core/IChangeEntity'; +import type {IActivityChange} from './IActivityChange'; import type {ICommitMetadata} from '../SIL/Harmony/Core/ICommitMetadata'; export interface IProjectActivity { commitId: string; timestamp: string; - changes: IChangeEntity[]; + changes: IActivityChange[]; metadata: ICommitMetadata; changeName: string; - changeTypes: string[]; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts index e4fe82c420..f2e7245c16 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts @@ -6,5 +6,8 @@ export * from './UserProjectRole' export * from './IChangeContext' export * from './IActivityAuthor' export * from './IActivityChangeType' +export * from './IActivityChange' +export * from './IActivityChangeInfo' export * from './IActivityQuery' export * from './ActivitySort' +export * from './ChangeType' diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index fc2c310b80..51d0dd001f 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -103,7 +103,7 @@
{#if record} - + {/if}