From a21704aa7196cd3db515df7ca11c0f3ce7faff89 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 29 Jun 2026 16:13:22 +0200 Subject: [PATCH 01/51] Rework the activity view: readable change summaries and a before/after diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity list now says what actually happened in each change — e.g. "Apfel › apple · Set Definition (en) to …", "Changed part of speech to Noun", "Removed semantic domain 5.2 Food" — instead of just naming the change type, with Simple and Detailed modes mirroring the browse view. The detail panel shows a real before/after diff of the entry (a read-only reuse of the editor layout) in place of the old current-version toggle. Each summary names its subject (entry headword, "headword › gloss" for a sense, a vocab object's name) and any item a change references only by id (the part of speech set, the domain/component removed or linked). Entry headwords carry their homograph number as a subscript when assigned. Those names are resolved in one batched pass per page, so naming adds no per-row queries. The list of change types is generated from the backend via Reinforced.Typings and a coverage test fails until every type has a summary. Co-Authored-By: Claude Opus 4.8 --- .../TypeGen/ChangeTypesCodeGenerator.cs | 41 +++ .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 3 + .../HistoryServiceActivityTests.cs | 21 ++ .../LcmCrdt/ActivityChangeInfoResolver.cs | 164 +++++++++ backend/FwLite/LcmCrdt/ChangeTypes.cs | 8 + backend/FwLite/LcmCrdt/HistoryService.cs | 59 +++- frontend/pnpm-lock.yaml | 11 +- frontend/viewer/package.json | 1 + .../activity/ActivityItemChangePreview.svelte | 72 +--- .../activity/ActivityListViewOptions.svelte | 36 ++ .../src/lib/activity/ActivityView.svelte | 33 +- .../src/lib/activity/ChangeSummary.svelte | 140 ++++++++ .../src/lib/activity/change-summary.test.ts | 178 ++++++++++ .../viewer/src/lib/activity/change-summary.ts | 314 ++++++++++++++++++ .../generated-types/LcmCrdt/ChangeTypes.ts | 119 +++++++ .../LcmCrdt/IActivityChangeInfo.ts | 12 + .../generated-types/LcmCrdt/IChangeContext.ts | 1 + .../LcmCrdt/IProjectActivity.ts | 2 + .../diff-view/DiffEntryPrimitive.svelte | 74 +++++ .../diff-view/DiffExamplePrimitive.svelte | 59 ++++ .../diff-view/DiffMultiSelect.svelte | 35 ++ .../diff-view/DiffMultiString.svelte | 26 ++ .../diff-view/DiffRichText.svelte | 27 ++ .../entry-editor/diff-view/DiffSelect.svelte | 25 ++ .../diff-view/DiffSensePrimitive.svelte | 59 ++++ .../entry-editor/diff-view/DiffShell.svelte | 13 + .../entry-editor/diff-view/DiffText.svelte | 26 ++ .../entry-editor/diff-view/DiffValue.svelte | 21 ++ .../diff-view/diff-field-coverage.test.ts | 36 ++ .../diff-view/inline-diff.test.ts | 25 ++ .../lib/entry-editor/diff-view/inline-diff.ts | 24 ++ .../viewer/src/lib/history/HistoryView.svelte | 2 +- .../src/lib/storage/project-storage.svelte.ts | 2 + frontend/viewer/src/locales/en.po | 267 ++++++++++++++- frontend/viewer/src/locales/es.po | 264 ++++++++++++++- frontend/viewer/src/locales/fr.po | 264 ++++++++++++++- frontend/viewer/src/locales/id.po | 264 ++++++++++++++- frontend/viewer/src/locales/ko.po | 264 ++++++++++++++- frontend/viewer/src/locales/ms.po | 264 ++++++++++++++- frontend/viewer/src/locales/sw.po | 264 ++++++++++++++- frontend/viewer/src/locales/vi.po | 264 ++++++++++++++- .../stories/activity/summaries.stories.svelte | 47 +++ .../editor/diff/diff-overview.stories.svelte | 145 ++++++++ 43 files changed, 3831 insertions(+), 145 deletions(-) create mode 100644 backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs create mode 100644 backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs create mode 100644 backend/FwLite/LcmCrdt/ChangeTypes.cs create mode 100644 frontend/viewer/src/lib/activity/ActivityListViewOptions.svelte create mode 100644 frontend/viewer/src/lib/activity/ChangeSummary.svelte create mode 100644 frontend/viewer/src/lib/activity/change-summary.test.ts create mode 100644 frontend/viewer/src/lib/activity/change-summary.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ChangeTypes.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeInfo.ts create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffEntryPrimitive.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffExamplePrimitive.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffMultiSelect.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffMultiString.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffRichText.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffSelect.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffSensePrimitive.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffShell.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffText.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/DiffValue.svelte create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/diff-field-coverage.test.ts create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/inline-diff.test.ts create mode 100644 frontend/viewer/src/lib/entry-editor/diff-view/inline-diff.ts create mode 100644 frontend/viewer/src/stories/activity/summaries.stories.svelte create mode 100644 frontend/viewer/src/stories/editor/diff/diff-overview.stories.svelte diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs new file mode 100644 index 0000000000..633b915a89 --- /dev/null +++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs @@ -0,0 +1,41 @@ +using System.Reflection; +using LcmCrdt; +using Reinforced.Typings; +using Reinforced.Typings.Ast; +using Reinforced.Typings.Generators; + +namespace FwLiteShared.TypeGen; + +/// +/// Emits a `ChangeType` string-literal union and a `knownChangeTypes` array built from the registered CRDT +/// change types () and each type's IPolyType.TypeName +/// (the serialized $type discriminator). Attached to the ChangeTypes marker; suppresses the +/// marker's own output. +/// +public class ChangeTypesCodeGenerator : ClassCodeGenerator +{ + public override RtClass GenerateNode(Type element, RtClass result, TypeResolver resolver) + { + var typeNames = LcmCrdtKernel.AllChangeTypes() + .Select(GetChangeTypeName) + .Distinct() + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + var union = string.Concat(typeNames.Select(name => $"\n | '{name}'")); + var array = string.Concat(typeNames.Select(name => $"\n '{name}',")); + + Context.Location.CurrentNamespace.CompilationUnits.Add(new RtRaw( + $"export type ChangeType ={union};\n\nexport const knownChangeTypes = [{array}\n] as const satisfies readonly ChangeType[];\n")); + + // Suppress the marker class; only the raw union/array above is emitted. + return null!; + } + + private static string GetChangeTypeName(Type changeType) + { + var typeNameProperty = changeType.GetProperty("TypeName", + BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy); + return typeNameProperty?.GetValue(null) as string ?? changeType.Name; + } +} diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index ccbdbed6b1..7219298d82 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -175,6 +175,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(FwLiteConfig), typeof(HistoryLineItem), typeof(ProjectActivity), + typeof(ActivityChangeInfo), typeof(ActivityAuthor), typeof(ActivityChangeType), typeof(ActivityQuery), @@ -188,6 +189,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(AvailableUpdate), ], exportBuilder => exportBuilder.WithPublicProperties()); + builder.ExportAsClass().WithCodeGenerator(); + builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(false); diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs index dd53c90a69..65ebc96d5d 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -167,6 +167,27 @@ public async Task ProjectActivity_IncludesChangeTypes() activity.ChangeTypes.Should().Contain("CreateEntryChange"); } + [Fact] + public async Task ProjectActivity_ChangeInfo_AddsHomographSubscriptToSubject_OnlyWhenAssigned() + { + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = new MultiString { ["en"] = "plain" } + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + await DataModel.AddChange(ClientId, new CreateEntryChange(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = new MultiString { ["en"] = "homograph" }, + HomographNumber = 2 + }), new CommitMetadata { AuthorName = "A", AuthorId = "a" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery()).ToArrayAsync(); + + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "plain"); + activities.Should().Contain(a => a.ChangeInfo.Count == 1 && a.ChangeInfo[0].Subject == "homograph₂"); + } + private async Task AddEntryCommit(CommitMetadata metadata, string? headword = null) { var entry = headword is null diff --git a/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs new file mode 100644 index 0000000000..c6e84a96e5 --- /dev/null +++ b/backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs @@ -0,0 +1,164 @@ +using LcmCrdt.Changes; +using LcmCrdt.Changes.Entries; +using LinqToDB; +using LinqToDB.Async; +using LinqToDB.EntityFrameworkCore; +using MiniLcm.Models; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Db; + +namespace LcmCrdt; + +/// +/// Batch-resolves a human label (and the root entry) for each change in a page of activity, so summaries +/// can name the entry/sense/vocab object a change is about without a per-row lookup. Degradable: leaves +/// null for types it doesn't resolve (the frontend falls back to a type label). +/// Reads the projected snapshot tables, so labels reflect the current state; deleted entities get the "(Unknown)" headword. +/// +internal static class ActivityChangeInfoResolver +{ + public static async Task ResolveAsync(ICrdtDbContext db, IReadOnlyList activities) + { + var entryIds = new HashSet(); + var senseIds = new HashSet(); + var exampleIds = new HashSet(); + var componentIds = new HashSet(); + var partOfSpeechIds = new HashSet(); + var semanticDomainIds = new HashSet(); + var publicationIds = new HashSet(); + var complexFormTypeIds = new HashSet(); + var morphTypeIds = new HashSet(); + + foreach (var change in activities.SelectMany(a => a.Changes)) + { + switch (BucketFor(change.Change.EntityType)) + { + case nameof(Entry): entryIds.Add(change.EntityId); break; + case nameof(Sense): senseIds.Add(change.EntityId); break; + case nameof(ExampleSentence): exampleIds.Add(change.EntityId); break; + case nameof(ComplexFormComponent): componentIds.Add(change.EntityId); break; + case nameof(PartOfSpeech): partOfSpeechIds.Add(change.EntityId); break; + case nameof(SemanticDomain): semanticDomainIds.Add(change.EntityId); break; + case nameof(Publication): publicationIds.Add(change.EntityId); break; + case nameof(ComplexFormType): complexFormTypeIds.Add(change.EntityId); break; + case nameof(MorphType): morphTypeIds.Add(change.EntityId); break; + } + + // Some changes name a referenced item only by id; fold those ids into the same batch-loads so we can label them. + switch (change.Change) + { + case SetPartOfSpeechChange { PartOfSpeechId: { } posId }: partOfSpeechIds.Add(posId); break; + case RemoveSemanticDomainChange r: semanticDomainIds.Add(r.SemanticDomainId); break; + case RemovePublicationChange r: publicationIds.Add(r.PublicationId); break; + case RemoveComplexFormTypeChange r: complexFormTypeIds.Add(r.ComplexFormTypeId); break; + case AddEntryComponentChange a: entryIds.Add(a.ComponentEntryId); break; + case SetComplexFormComponentChange { ComponentEntryId: { } cid }: entryIds.Add(cid); break; + } + } + + // Walk down to the root entry: component → entry, example → sense → entry. + var components = await LoadByIds(db, componentIds); + foreach (var component in components.Values) entryIds.Add(component.ComplexFormEntryId); + var examples = await LoadByIds(db, exampleIds); + foreach (var example in examples.Values) senseIds.Add(example.SenseId); + var senses = await LoadByIds(db, senseIds); + foreach (var sense in senses.Values) entryIds.Add(sense.EntryId); + + var entries = await LoadByIds(db, entryIds); + var partsOfSpeech = await LoadByIds(db, partOfSpeechIds); + var semanticDomains = await LoadByIds(db, semanticDomainIds); + var publications = await LoadByIds(db, publicationIds); + var complexFormTypes = await LoadByIds(db, complexFormTypeIds); + var morphTypes = await LoadByIds(db, morphTypeIds); + + string Headword(Guid entryId) => entries.TryGetValue(entryId, out var entry) ? HeadwordWithHomograph(entry) : Entry.UnknownHeadword; + + foreach (var activity in activities) + { + activity.ChangeInfo = activity.Changes + .Select(change => Build(change, Headword)) + .ToList(); + } + + ActivityChangeInfo Build(ChangeEntity change, Func headword) + { + var (subject, rootEntryId) = ResolveSubject(change, headword); + return new ActivityChangeInfo(subject, rootEntryId, TargetLabel(change)); + } + + (string? Subject, Guid? RootEntryId) ResolveSubject(ChangeEntity change, Func headword) + { + var id = change.EntityId; + switch (BucketFor(change.Change.EntityType)) + { + case nameof(Entry): + return (headword(id), id); + case nameof(Sense) when senses.TryGetValue(id, out var sense): + return (SenseLabel(headword(sense.EntryId), sense.Gloss), sense.EntryId); + case nameof(ExampleSentence) when examples.TryGetValue(id, out var ex) && senses.TryGetValue(ex.SenseId, out var exSense): + return (SenseLabel(headword(exSense.EntryId), exSense.Gloss), 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); + default: + return (null, null); + } + } + + // The item a change references only by id (the part of speech set, the domain/publication/type removed). + string? TargetLabel(ChangeEntity change) => change.Change switch + { + SetPartOfSpeechChange { PartOfSpeechId: { } id } when partsOfSpeech.TryGetValue(id, out var pos) => Label(pos.Name), + RemoveSemanticDomainChange r when semanticDomains.TryGetValue(r.SemanticDomainId, out var domain) => SemanticDomainLabel(domain), + RemovePublicationChange r when publications.TryGetValue(r.PublicationId, out var publication) => Label(publication.Name), + RemoveComplexFormTypeChange r when complexFormTypes.TryGetValue(r.ComplexFormTypeId, out var cft) => Label(cft.Name), + // Component links resolve the component being linked (the change's subject is the complex form). + AddEntryComponentChange a when entries.TryGetValue(a.ComponentEntryId, out var component) => HeadwordWithHomograph(component), + SetComplexFormComponentChange { ComponentEntryId: { } cid } when entries.TryGetValue(cid, out var component) => HeadwordWithHomograph(component), + _ => null + }; + } + + private static string BucketFor(Type entityType) => entityType.Name; + + private static async Task> LoadByIds(ICrdtDbContext db, HashSet ids) + where T : class, IObjectWithId + { + if (ids.Count == 0) return []; + var loaded = await db.Set().Where(o => ids.Contains(o.Id)).ToListAsyncLinqToDB(); + return loaded.ToDictionary(o => o.Id); + } + + private static string? SenseLabel(string headword, MultiString gloss) + { + var glossText = Label(gloss); + return glossText is null ? headword : $"{headword} › {glossText}"; + } + + private static string? Label(MultiString multiString) => + multiString.Values.Select(kvp => kvp.Value?.Trim()).FirstOrDefault(text => !string.IsNullOrEmpty(text)); + + // FieldWorks distinguishes same-spelled entries by a homograph number shown as a subscript; it's only assigned (> 0) when there's a collision. + private static string HeadwordWithHomograph(Entry entry) + { + var headword = entry.Headword(); + return entry.HomographNumber > 0 ? headword + Subscript(entry.HomographNumber) : headword; + } + + private static string Subscript(int number) => + new(number.ToString().Select(digit => (char)('₀' + (digit - '0'))).ToArray()); + + // The app shows a domain as "code name" (e.g. "5.2 Food"); the code alone is too cryptic to identify it. + private static string SemanticDomainLabel(SemanticDomain domain) => + Label(domain.Name) is { } name ? $"{domain.Code} {name}" : domain.Code; +} diff --git a/backend/FwLite/LcmCrdt/ChangeTypes.cs b/backend/FwLite/LcmCrdt/ChangeTypes.cs new file mode 100644 index 0000000000..7b0288f9db --- /dev/null +++ b/backend/FwLite/LcmCrdt/ChangeTypes.cs @@ -0,0 +1,8 @@ +namespace LcmCrdt; + +/// +/// Marker type only — no members. Reinforced.Typings (via ChangeTypesCodeGenerator) emits the +/// ChangeType string-literal union and knownChangeTypes array into ChangeTypes.ts from the +/// registered change types, so the frontend has a generated, exhaustive list of change $type values. +/// +public sealed class ChangeTypes; diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index 2c6407a7c9..ce49f8bc6d 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -36,6 +36,14 @@ public static class ActivityFilterKeys public const string AuthorNamePrefix = "name:"; } +/// +/// Resolved display info for one change, so the frontend can name what the change is about. +/// is the entity the change is on (entry headword, "headword › gloss" for a sense, or a vocab object's name); +/// is a referenced item the change names only by id (e.g. the part of speech assigned, the semantic domain removed). +/// Both are null when unresolved — the frontend falls back to a type label. +/// +public record ActivityChangeInfo(string? Subject, Guid? RootEntryId, string? Target = null); + public record ProjectActivity( Guid CommitId, DateTimeOffset Timestamp, @@ -44,6 +52,8 @@ public record ProjectActivity( { public string ChangeName => HistoryService.ChangesNameHelper(Changes); public string[] ChangeTypes { get; } = Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct().ToArray(); + /// Resolved display info per change, parallel to by index. Set during enrichment. + public IReadOnlyList ChangeInfo { get; set; } = []; } public record ChangeContext( @@ -51,13 +61,14 @@ public record ChangeContext( int ChangeIndex, string ChangeName, IObjectWithId? Snapshot, + IObjectWithId? PreviousSnapshot, ICollection AffectedEntries) { - public ChangeContext(ChangeEntity change, IObjectWithId? snapshot, ICollection affectedEntries) - : this(change.CommitId, change.Index, HistoryService.ChangeNameHelper(change.Change), snapshot, affectedEntries) + public ChangeContext(ChangeEntity change, IObjectWithId? snapshot, IObjectWithId? previousSnapshot, ICollection affectedEntries) + : this(change.CommitId, change.Index, HistoryService.ChangeNameHelper(change.Change), snapshot, previousSnapshot, affectedEntries) { } - public string EntityType => Snapshot?.GetType().Name ?? "Unknown"; + public string EntityType => (Snapshot ?? PreviousSnapshot)?.GetType().Name ?? "Unknown"; } public record HistoryLineItem( @@ -143,7 +154,13 @@ from commit in commits.Skip(skip).Take(take) NormalizeTimestamp(commit.HybridDateTime.DateTime), commit.ChangeEntities.ToList(), commit.Metadata); + var activities = new List(); await foreach (var projectActivity in queryable.ToLinqToDB().AsAsyncEnumerable()) + { + activities.Add(projectActivity); + } + await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities); + foreach (var projectActivity in activities) { yield return projectActivity; } @@ -301,21 +318,41 @@ public async Task LoadChangeContext(Guid commitId, int changeInde // Use safe cast - some entity types like RemoteResource don't implement IObjectWithId var snapshot = await dataModel.GetAtCommit(commitId, change.EntityId) as IObjectWithId; + var previousSnapshot = await LoadPreviousSnapshot(crdtDbContext, commitId, change.EntityId); + + await ResolveSensePartOfSpeech(snapshot); + await ResolveSensePartOfSpeech(previousSnapshot); + + var affectedEntries = await GetAffectedEntryIds(change) + .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) + .ToArrayAsync(); + + return new ChangeContext(change, snapshot, previousSnapshot, affectedEntries); + } + /// The entity's state just before , i.e. the snapshot at the most recent + /// commit that affected it before this one. Null if this commit created the entity. + private async Task LoadPreviousSnapshot(ICrdtDbContext dbContext, Guid commitId, Guid entityId) + { + var affectingCommitIds = await dbContext.Commits + .Where(c => c.ChangeEntities.Any(ce => ce.EntityId == entityId)) + .DefaultOrderDescending() + .Select(c => c.Id) + .ToListAsyncLinqToDB(); + var index = affectingCommitIds.IndexOf(commitId); + if (index < 0 || index + 1 >= affectingCommitIds.Count) return null; + return await dataModel.GetAtCommit(affectingCommitIds[index + 1], entityId) as IObjectWithId; + } + + // Older sense snapshots didn't store the part-of-speech object, only its id; resolve it so previews can show a label. + private async Task ResolveSensePartOfSpeech(IObjectWithId? snapshot) + { if (snapshot is Sense sense && sense.PartOfSpeechId != sense.PartOfSpeech?.Id) { - // previously we didn't update the part of speech object on sense snapshots - // we do now, so we patch old snapshots sense.PartOfSpeech = sense.PartOfSpeechId.HasValue ? await dataModel.GetLatest(sense.PartOfSpeechId.Value) : null; } - - var affectedEntries = await GetAffectedEntryIds(change) - .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) - .ToArrayAsync(); - - return new ChangeContext(change, snapshot, affectedEntries); } private async Task GetCurrentOrLatestEntry(Guid entryId) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 160f6180f4..e51b8468c3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: '@microsoft/signalr': specifier: ^10.0.0 version: 10.0.0 + '@sanity/diff-match-patch': + specifier: ^3.2.0 + version: 3.2.0 fast-json-patch: specifier: ^3.1.1 version: 3.1.1 @@ -3066,6 +3069,10 @@ packages: cpu: [x64] os: [win32] + '@sanity/diff-match-patch@3.2.0': + resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} + engines: {node: '>=18.18'} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -10548,6 +10555,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@sanity/diff-match-patch@3.2.0': {} + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -11403,7 +11412,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.2.0)(lightningcss@1.31.1)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.15)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 645a52abe2..51e69de5ff 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -110,6 +110,7 @@ "@lingui/core": "^5.6.1", "@microsoft/dotnet-js-interop": "^10.0.0", "@microsoft/signalr": "^10.0.0", + "@sanity/diff-match-patch": "^3.2.0", "fast-json-patch": "^3.1.1", "jsdom": "^26.1.0", "just-throttle": "^4.2.0", diff --git a/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte b/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte index 60f1cb2e27..ca5119e5fd 100644 --- a/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItemChangePreview.svelte @@ -1,14 +1,9 @@ {#snippet entryButton(entry: IEntry)} @@ -90,58 +68,36 @@ {/snippet} -{#if affectedEntry || currentEntity} -
- {#if context.affectedEntries.length === 1} - - {@const entry = context.affectedEntries[0]} - {@render entryButton(entry)} - {/if} - - +{#if context.affectedEntries.length === 1} +
+ {@render entryButton(context.affectedEntries[0])}
{/if} -{#if !context.snapshot} +{#if !context.snapshot && !context.previousSnapshot}
- {$t`Preview not available`} + {$t`Preview not available for this type of change`}
{:else if context.entityType === 'Entry'} - + {:else if context.entityType === 'Sense'} - + {:else if context.entityType === 'ExampleSentence'} - + {:else if context.entityType === 'ComplexFormComponent'} - {@const cfc = context.snapshot as IComplexFormComponent} + {@const cfc = (context.snapshot ?? context.previousSnapshot) as IComplexFormComponent} {@const complexForm = context.affectedEntries.find(e => e.id === cfc.complexFormEntryId)} {@const component = context.affectedEntries.find(e => e.id === cfc.componentEntryId)}
@@ -164,6 +120,6 @@
{:else}
- {formatJsonForUi(context.snapshot)} + {formatJsonForUi(context.snapshot ?? context.previousSnapshot ?? {})}
{/if} diff --git a/frontend/viewer/src/lib/activity/ActivityListViewOptions.svelte b/frontend/viewer/src/lib/activity/ActivityListViewOptions.svelte new file mode 100644 index 0000000000..0361b507b2 --- /dev/null +++ b/frontend/viewer/src/lib/activity/ActivityListViewOptions.svelte @@ -0,0 +1,36 @@ + + + + + + {#snippet trigger({props})} + - {/if} -
- - - - {$t`Preview`} - - {$t`Details`} - -
- - - - -
- {formatJsonForUi(change)} -
-
-
-
- - {/await} - {/snippet} - - {/key} - + {#if collapseToEntry} + {#await collapsedEntry} +
+ {:then entry} + {#if entry} +
+ +
+ {:else} + {@render changeList(changes)} + {/if} + {/await} + {:else} + {@render changeList(changes)} + {/if} {/if} {/if} +{#snippet changeList(items: ChangeWithLazyContext[])} +
+ {#key items} + `${item.change.commitId}:${item.change.index}`}> + {#snippet children(changeWithContext)} + {@const {change, lazyContext} = changeWithContext} + {#await lazyContext} + +
+ {:then context} +
+
+ {context.changeName} + + {#if showHistoryButton} + + {/if} +
+ + + + {$t`Preview`} + + {$t`Details`} + +
+ + + + +
+ {formatJsonForUi(change)} +
+
+
+
+
+ {/await} + {/snippet} +
+ {/key} +
+{/snippet} +