From fbda954162ea1768558b66d47bf65286bfd6a120 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 16 Jun 2026 10:45:05 +0700 Subject: [PATCH 01/10] Enhance HistoryService with new activity querying features - Updated ProjectActivity method to accept additional parameters for filtering by author and sorting. - Introduced ListActivityAuthors and ListActivityChangeTypes methods to retrieve distinct authors and change types. - Added ActivityQuery record to encapsulate query parameters. - Implemented frontend components for filtering activities by author and change type. - Updated TypeGen configuration to include new types for activity authors and change types. - Added tests for new functionality in HistoryService. This commit improves the activity tracking capabilities and enhances the user experience by allowing more granular filtering of activities. --- .../Services/HistoryServiceJsInvokable.cs | 23 ++- .../TypeGen/ReinforcedFwLiteTypingConfig.cs | 4 + .../FwLite/FwLiteWeb/Routes/ActivityRoutes.cs | 12 +- .../HistoryServiceActivityTests.cs | 145 +++++++++++++++ backend/FwLite/LcmCrdt/HistoryService.cs | 153 +++++++++++++++- .../src/lib/activity/ActivityFilter.svelte | 121 +++++++++++++ .../src/lib/activity/ActivityView.svelte | 170 +++++++++++++----- frontend/viewer/src/lib/activity/utils.ts | 76 +++++++- .../Services/IHistoryServiceJsInvokable.ts | 7 +- .../generated-types/LcmCrdt/ActivitySort.ts | 12 ++ .../LcmCrdt/IActivityAuthor.ts | 12 ++ .../LcmCrdt/IActivityChangeType.ts | 12 ++ .../generated-types/LcmCrdt/IActivityQuery.ts | 15 ++ .../LcmCrdt/IProjectActivity.ts | 1 + .../generated-types/LcmCrdt/index.ts | 4 + .../viewer/src/lib/history/HistoryView.svelte | 2 +- .../src/lib/services/history-service.ts | 59 ++++-- .../lib/services/service-provider-dotnet.ts | 11 +- frontend/viewer/src/locales/en.po | 60 ++++++- frontend/viewer/src/locales/es.po | 60 ++++++- frontend/viewer/src/locales/fr.po | 60 ++++++- frontend/viewer/src/locales/id.po | 60 ++++++- frontend/viewer/src/locales/ko.po | 60 ++++++- frontend/viewer/src/locales/ms.po | 60 ++++++- frontend/viewer/src/locales/sw.po | 60 ++++++- frontend/viewer/src/locales/vi.po | 60 ++++++- 26 files changed, 1226 insertions(+), 93 deletions(-) create mode 100644 backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs create mode 100644 frontend/viewer/src/lib/activity/ActivityFilter.svelte create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ActivitySort.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityAuthor.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts diff --git a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs index 3b86591a09..24b8e5cc25 100644 --- a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs @@ -14,9 +14,28 @@ public Task GetObject(Guid commitId, Guid entityId) } [JSInvokable] - public async ValueTask ProjectActivity(int skip, int take) + public async ValueTask ProjectActivity( + int skip, + int take, + string? authorId = null, + string? authorName = null, + bool excludeFieldWorks = false, + ActivitySort sort = ActivitySort.NewestFirst) { - return await historyService.ProjectActivity(skip, take).ToArrayAsync(); + return await historyService.ProjectActivity(skip, take, + new ActivityQuery(authorId, authorName, excludeFieldWorks, sort)).ToArrayAsync(); + } + + [JSInvokable] + public Task ListActivityAuthors() + { + return historyService.ListActivityAuthors(); + } + + [JSInvokable] + public Task ListActivityChangeTypes() + { + return historyService.ListActivityChangeTypes(); } [JSInvokable] diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index ccca38d907..05b53dd669 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -174,6 +174,9 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(FwLiteConfig), typeof(HistoryLineItem), typeof(ProjectActivity), + typeof(ActivityAuthor), + typeof(ActivityChangeType), + typeof(ActivityQuery), typeof(ChangeContext), typeof(ChangeEntity), typeof(IChange), @@ -184,6 +187,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder) typeof(AvailableUpdate), ], exportBuilder => exportBuilder.WithPublicProperties()); + builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(false); var eventJsAttrs = typeof(IFwEvent).GetCustomAttributes(); diff --git a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs index 410b57269d..7c5f197fef 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs @@ -18,7 +18,17 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) }); return Task.CompletedTask; }); - group.MapGet("/", (HistoryService historyService, int skip, int take) => historyService.ProjectActivity(skip, take)); + group.MapGet("/", ( + HistoryService historyService, + int skip = 0, + int take = 100, + string? authorId = null, + string? authorName = null, + bool excludeFieldWorks = false, + ActivitySort sort = ActivitySort.NewestFirst) => + historyService.ProjectActivity(skip, take, new ActivityQuery(authorId, authorName, excludeFieldWorks, sort))); + group.MapGet("/authors", (HistoryService historyService) => historyService.ListActivityAuthors()); + group.MapGet("/change-types", (HistoryService historyService) => historyService.ListActivityChangeTypes()); return group; } } diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs new file mode 100644 index 0000000000..8e20f32026 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -0,0 +1,145 @@ +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 +{ + 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() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + await AddEntryCommit(new CommitMetadata { AuthorName = "Bob", AuthorId = "bob-id" }); + + var authors = await Service.ListActivityAuthors(); + + authors.Should().Contain(a => a.AuthorId == "alice-id" && a.AuthorName == "Alice" && a.CommitCount == 2); + authors.Should().Contain(a => a.AuthorId == "bob-id" && a.AuthorName == "Bob" && a.CommitCount == 1); + } + + [Fact] + public async Task ListActivityChangeTypes_IncludesCreateEntry() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + + var changeTypes = await Service.ListActivityChangeTypes(); + + changeTypes.Should().Contain(t => t.Key == "CreateEntryChange" && t.CommitCount >= 1); + changeTypes.Single(t => t.Key == "CreateEntryChange").Label.Should().Be("Create entry"); + } + + [Fact] + 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(AuthorId: "alice-id")).ToArrayAsync(); + + activities.Should().OnlyContain(a => a.Metadata.AuthorId == "alice-id"); + activities.Should().HaveCountGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task ProjectActivity_ExcludeFieldWorks_HidesFieldWorksCommits() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + await AddEntryCommit(new CommitMetadata { AuthorName = "FieldWorks" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ExcludeFieldWorks: true)).ToArrayAsync(); + + activities.Should().NotContain(a => a.Metadata.AuthorName == "FieldWorks"); + activities.Should().Contain(a => a.Metadata.AuthorName == "Alice"); + } + + [Fact] + public async Task ProjectActivity_SortsOldestFirst() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "First", AuthorId = "first" }, "alpha"); + 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 firstIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "first"); + var secondIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "second"); + firstIndex.Should().BeGreaterThanOrEqualTo(0); + secondIndex.Should().BeGreaterThan(firstIndex); + } + + [Fact] + public async Task ProjectActivity_SyncedSort_PlacesUnsyncedLast() + { + var syncedCommit = await AddEntryCommit(new CommitMetadata { AuthorName = "Synced", AuthorId = "synced" }, "synced-entry"); + 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 syncedIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "synced"); + var unsyncedIndex = Array.FindIndex(activities, a => a.Metadata.AuthorId == "unsynced"); + syncedIndex.Should().BeGreaterThanOrEqualTo(0); + unsyncedIndex.Should().BeGreaterThanOrEqualTo(0); + syncedIndex.Should().BeLessThan(unsyncedIndex); + } + + [Fact] + public async Task ProjectActivity_PaginationRespectsFilters() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + 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(AuthorId: "alice-id")).ToArrayAsync(); + + page.Should().HaveCount(1); + page[0].Metadata.AuthorId.Should().Be("alice-id"); + } + + [Fact] + public async Task ProjectActivity_IncludesChangeTypes() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + + var activity = await Service.ProjectActivity(0, 1).SingleAsync(); + + 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 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/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index a7fc86a344..e83aa86307 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -5,12 +5,32 @@ using SIL.Harmony.Db; using LinqToDB; using LinqToDB.EntityFrameworkCore; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; using MiniLcm.Exceptions; using LinqToDB.Async; namespace LcmCrdt; +public record ActivityAuthor(string? AuthorId, string? AuthorName, int CommitCount); + +public record ActivityChangeType(string Key, string Label, int CommitCount); + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ActivitySort +{ + NewestFirst = 0, + OldestFirst = 1, + SyncedNewestFirst = 2, + SyncedOldestFirst = 3, +} + +public record ActivityQuery( + string? AuthorId = null, + string? AuthorName = null, + bool ExcludeFieldWorks = false, + ActivitySort Sort = ActivitySort.NewestFirst); + public record ProjectActivity( Guid CommitId, DateTimeOffset Timestamp, @@ -18,6 +38,7 @@ public record ProjectActivity( CommitMetadata Metadata) { public string ChangeName => HistoryService.ChangesNameHelper(Changes); + public string[] ChangeTypes { get; } = Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct().ToArray(); } public record ChangeContext( @@ -67,26 +88,146 @@ public HistoryLineItem( public class HistoryService(DataModel dataModel, Microsoft.EntityFrameworkCore.IDbContextFactory dbContextFactory, IMiniLcmApi miniLcmApi) { - public async IAsyncEnumerable ProjectActivity(int skip = 0, int take = 100) + + public async Task ListActivityAuthors() + { + await using ICrdtDbContext dbContext = await dbContextFactory.CreateDbContextAsync(); + var authors = await dbContext.Commits + .GroupBy(c => new + { + AuthorId = Json.Value(c.Metadata, m => m.AuthorId), + AuthorName = Json.Value(c.Metadata, m => m.AuthorName), + }) + .Select(g => new ActivityAuthor(g.Key.AuthorId, g.Key.AuthorName, g.Count())) + .ToListAsyncLinqToDB(); + return authors.OrderBy(a => a.AuthorName ?? "").ThenBy(a => a.AuthorId ?? "").ToArray(); + } + + public async Task ListActivityChangeTypes() { + await using ICrdtDbContext dbContext = await dbContextFactory.CreateDbContextAsync(); + var distinctCommitCounts = new Dictionary>(); + await foreach (var changeEntity in dbContext.Set>().ToLinqToDB().AsAsyncEnumerable()) + { + var key = GetChangeTypeKey(changeEntity.Change); + if (!distinctCommitCounts.TryGetValue(key, out var commits)) + { + commits = []; + distinctCommitCounts[key] = commits; + } + commits.Add(changeEntity.CommitId); + } + + var registeredTypes = LcmCrdtKernel.AllChangeTypes() + .Select(t => new ActivityChangeType( + GetChangeTypeKeyFromType(t), + ChangeTypeLabel(t), + distinctCommitCounts.GetValueOrDefault(GetChangeTypeKeyFromType(t))?.Count ?? 0)) + .Where(t => t.CommitCount > 0) + .OrderBy(t => t.Label) + .ToArray(); + + return registeredTypes; + } + + public async IAsyncEnumerable ProjectActivity(int skip = 0, int take = 100, ActivityQuery? query = null) + { + query ??= new ActivityQuery(); await using ICrdtDbContext dbContext = await dbContextFactory.CreateDbContextAsync(); var changeEntities = dbContext.Set>(); - var query = - from commit in dbContext.Commits.DefaultOrderDescending() + var commits = ApplyActivityFilters(dbContext.Commits, query); + commits = ApplyActivitySort(commits, query.Sort); + var queryable = + from commit in commits.Skip(skip).Take(take) join changeEntity in changeEntities on commit.Id equals changeEntity.CommitId into changes - join snapshot in dbContext.Snapshots - on commit.Id equals snapshot.CommitId into snapshots select new ProjectActivity(commit.Id, NormalizeTimestamp(commit.HybridDateTime.DateTime), changes.ToList(), commit.Metadata); - await foreach (var projectActivity in query.Skip(skip).Take(take).ToLinqToDB().AsAsyncEnumerable()) + await foreach (var projectActivity in queryable.ToLinqToDB().AsAsyncEnumerable()) { yield return projectActivity; } } + private static IQueryable ApplyActivityFilters(IQueryable commits, ActivityQuery query) + { + if (query.ExcludeFieldWorks) + { + commits = commits.ToLinqToDB().Where(c => + (Json.Value(c.Metadata, m => m.AuthorName) ?? "") != "FieldWorks"); + } + + if (query.AuthorId == "") + { + commits = commits.ToLinqToDB().Where(c => + (Json.Value(c.Metadata, m => m.AuthorId) ?? "") == "" + && (Json.Value(c.Metadata, m => m.AuthorName) ?? "") == ""); + } + else if (query.AuthorId is not null) + { + commits = commits.ToLinqToDB().Where(c => + Json.Value(c.Metadata, m => m.AuthorId) == query.AuthorId); + } + else if (query.AuthorName is not null) + { + commits = commits.ToLinqToDB().Where(c => + Json.Value(c.Metadata, m => m.AuthorName) == query.AuthorName); + } + + return commits; + } + + private static IQueryable ApplyActivitySort(IQueryable commits, ActivitySort sort) + { + return sort switch + { + ActivitySort.OldestFirst => commits.DefaultOrder(), + ActivitySort.SyncedNewestFirst => commits.ToLinqToDB() + .OrderBy(c => Sql.Expr( + "CASE WHEN json_extract({0}, '$.ExtraMetadata.SyncDate') IS NULL THEN 1 ELSE 0 END", c.Metadata)) + .ThenByDescending(c => Sql.Expr( + "json_extract({0}, '$.ExtraMetadata.SyncDate')", c.Metadata)) + .ThenByDescending(c => c.HybridDateTime.DateTime) + .ThenByDescending(c => c.HybridDateTime.Counter) + .ThenByDescending(c => c.Id), + ActivitySort.SyncedOldestFirst => commits.ToLinqToDB() + .OrderBy(c => Sql.Expr( + "CASE WHEN json_extract({0}, '$.ExtraMetadata.SyncDate') IS NULL THEN 1 ELSE 0 END", c.Metadata)) + .ThenBy(c => Sql.Expr( + "json_extract({0}, '$.ExtraMetadata.SyncDate')", c.Metadata)) + .ThenBy(c => c.HybridDateTime.DateTime) + .ThenBy(c => c.HybridDateTime.Counter) + .ThenBy(c => c.Id), + _ => commits.DefaultOrderDescending(), + }; + } + + private static string GetChangeTypeKeyFromType(Type changeType) + { + var typeNameProp = changeType.GetProperty("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)) + return $"Edit{changeType.GetGenericArguments()[0].Name}".Humanize(); + if (changeType.IsGenericType && changeType.GetGenericTypeDefinition() == typeof(DeleteChange<>)) + return $"Delete{changeType.GetGenericArguments()[0].Name}".Humanize(); + if (changeType.IsGenericType && changeType.Name.StartsWith("SetOrderChange", StringComparison.Ordinal)) + return $"Reorder{changeType.GetGenericArguments()[0].Name}".Humanize(); + var changeName = changeType.Name.Humanize(); + return Regex.Replace(changeName, " Change$", "", RegexOptions.IgnoreCase); + } + public async Task GetSnapshot(Guid snapshotId) { await using ICrdtDbContext dbContext = await dbContextFactory.CreateDbContextAsync(); diff --git a/frontend/viewer/src/lib/activity/ActivityFilter.svelte b/frontend/viewer/src/lib/activity/ActivityFilter.svelte new file mode 100644 index 0000000000..13e9d78bc6 --- /dev/null +++ b/frontend/viewer/src/lib/activity/ActivityFilter.svelte @@ -0,0 +1,121 @@ + + +
+ + filters.authorFilter = v}> + + {#if filters.authorFilter === ALL_AUTHORS} + {$t`All authors`} + {:else if filters.authorFilter === UNKNOWN_AUTHOR} + {$t`Unknown`} + {:else} + {@const author = authors.current.find(a => authorFilterKey(a) === filters.authorFilter)} + {author?.authorName ?? filters.authorFilter} + {/if} + + + {$t`All authors`} + {#each authors.current as author (authorFilterKey(author))} + {@const key = authorFilterKey(author)} + + {author.authorName ?? $t`Unknown`} + ({author.commitCount}) + + {/each} + + + + filters.changeTypeFilter = v}> + + {#if filters.changeTypeFilter === ALL_CHANGE_TYPES} + {$t`All change types`} + {:else} + {changeTypes.current.find(ct => ct.key === filters.changeTypeFilter)?.label ?? filters.changeTypeFilter} + {/if} + + + {$t`All change types`} + {#each changeTypes.current as changeType (changeType.key)} + + {changeType.label} + ({changeType.commitCount}) + + {/each} + + + + + + + + {#snippet child({props})} + + {/snippet} + + + {#each Object.values(ActivitySort) as sortOption (sortOption)} + filters.sort = sortOption} + class={cn(filters.sort === sortOption && 'bg-muted')}> + {sortLabels[sortOption]} + + {/each} + + +
diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index 0604703422..4b442c4f0e 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -1,69 +1,157 @@ -{#if activity.current.length} -
+
- -
+
+ + {#if loading.current} + + {/if} +
- + {#if activity.error && awaitingFreshData} +
+ +

{$t`Failed to load activity`}

+
+ {:else if awaitingFreshData} +
+ +
+ {:else if visibleActivity && visibleActivity.length} + row.commitId} bufferSize={400}> + getKey={row => row.commitId} bufferSize={400}> {#snippet children(row)} selectedRow = row} @@ -76,18 +164,18 @@ actualDateOptions={{ dateStyle: 'medium', timeStyle: 'short' }}/> - {row.metadata.authorName} + {row.metadata.authorName ?? $t`Unknown`}
{/snippet} - {#if activity.current.length === 0} -
{$t`No activity found`}
- {/if} -
- {#if selectedRow} - + {:else if !loading.current} +
{$t`No activity matches these filters`}
{/if}
-{/if} + + {#if selectedRow} + + {/if} + diff --git a/frontend/viewer/src/lib/activity/utils.ts b/frontend/viewer/src/lib/activity/utils.ts index a20941e2c2..4646297c27 100644 --- a/frontend/viewer/src/lib/activity/utils.ts +++ b/frontend/viewer/src/lib/activity/utils.ts @@ -1,7 +1,75 @@ +import {ActivitySort, type IActivityAuthor, type IActivityQuery, type IProjectActivity} from '$lib/dotnet-types'; + +export const ALL_AUTHORS = '__all__'; +export const UNKNOWN_AUTHOR = '__unknown__'; +export const ALL_CHANGE_TYPES = '__all__'; +export const MIN_VISIBLE_FILTERED = 20; + +export type ActivityLoad = { + items: IProjectActivity[]; + hasMorePages: boolean; + queryKey: string; +}; + +export const emptyActivityLoad: ActivityLoad = { + items: [], + hasMorePages: true, + queryKey: '', +}; + +export type ActivityFilters = { + authorFilter: string; + changeTypeFilter: string; + excludeFieldWorks: boolean; + sort: ActivitySort; +}; + +export function createDefaultActivityFilters(): ActivityFilters { + return { + authorFilter: ALL_AUTHORS, + changeTypeFilter: ALL_CHANGE_TYPES, + excludeFieldWorks: false, + sort: ActivitySort.NewestFirst, + }; +} + +export function toServerQuery(filters: ActivityFilters): IActivityQuery { + return { + ...parseAuthorFilter(filters.authorFilter), + excludeFieldWorks: filters.excludeFieldWorks, + sort: filters.sort, + }; +} + +export function serverQueryKey(filters: ActivityFilters): string { + return JSON.stringify(toServerQuery(filters)); +} + export function formatJsonForUi(json: object) { return JSON.stringify(json, null, 2) - .split('\n') // Split into lines - .slice(1, -1) // Remove the first and last line - .map(line => line.slice(2)) // Remove one level of indentation - .join('\n'); // Join the lines back together; + .split('\n') + .slice(1, -1) + .map(line => line.slice(2)) + .join('\n'); +} + +export function authorFilterKey(author: IActivityAuthor): string { + if (!author.authorId && !author.authorName) return UNKNOWN_AUTHOR; + if (author.authorId) return author.authorId; + return `name:${author.authorName}`; +} + +export function parseAuthorFilter(key: string): Pick { + if (key === ALL_AUTHORS) return {}; + if (key === UNKNOWN_AUTHOR) return {authorId: ''}; + if (key.startsWith('name:')) return {authorName: key.slice(5)}; + return {authorId: key}; +} + +export function filterActivityByChangeType( + activities: IProjectActivity[], + changeTypeKey: string, +): IProjectActivity[] { + if (changeTypeKey === ALL_CHANGE_TYPES) return activities; + return activities.filter(a => a.changeTypes?.includes(changeTypeKey)); } diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts index 2a3268dfe7..c003d92f07 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts @@ -5,6 +5,9 @@ import type {IObjectWithId} from '../../MiniLcm/Models/IObjectWithId'; import type {IProjectActivity} from '../../LcmCrdt/IProjectActivity'; +import type {ActivitySort} from '../../LcmCrdt/ActivitySort'; +import type {IActivityAuthor} from '../../LcmCrdt/IActivityAuthor'; +import type {IActivityChangeType} from '../../LcmCrdt/IActivityChangeType'; import type {IObjectSnapshot} from '../../SIL/Harmony/Db/IObjectSnapshot'; import type {IHistoryLineItem} from '../../LcmCrdt/IHistoryLineItem'; import type {IChangeContext} from '../../LcmCrdt/IChangeContext'; @@ -12,7 +15,9 @@ import type {IChangeContext} from '../../LcmCrdt/IChangeContext'; export interface IHistoryServiceJsInvokable { getObject(commitId: string, entityId: string) : Promise; - projectActivity(skip: number, take: number) : Promise; + projectActivity(skip: number, take: number, authorId?: string, authorName?: string, excludeFieldWorks?: boolean, sort?: ActivitySort) : Promise; + listActivityAuthors() : Promise; + listActivityChangeTypes() : Promise; getSnapshot(snapshotId: string) : Promise; getHistory(entityId: string) : Promise; loadChangeContext(commitId: string, changeIndex: number) : Promise; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ActivitySort.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ActivitySort.ts new file mode 100644 index 0000000000..7ba45f7936 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/ActivitySort.ts @@ -0,0 +1,12 @@ +/* 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 enum ActivitySort { + NewestFirst = "NewestFirst", + OldestFirst = "OldestFirst", + SyncedNewestFirst = "SyncedNewestFirst", + SyncedOldestFirst = "SyncedOldestFirst" +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityAuthor.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityAuthor.ts new file mode 100644 index 0000000000..a2a9def863 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityAuthor.ts @@ -0,0 +1,12 @@ +/* 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 IActivityAuthor +{ + authorId?: string; + authorName?: string; + commitCount: number; +} +/* 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 new file mode 100644 index 0000000000..85e76c74ff --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityChangeType.ts @@ -0,0 +1,12 @@ +/* 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 IActivityChangeType +{ + key: string; + label: string; + commitCount: number; +} +/* eslint-enable */ 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 new file mode 100644 index 0000000000..e3a1e50828 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts @@ -0,0 +1,15 @@ +/* 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 {ActivitySort} from './ActivitySort'; + +export interface IActivityQuery +{ + authorId?: string; + authorName?: string; + excludeFieldWorks: boolean; + sort: ActivitySort; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts index b83de5bead..52e2e03c30 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 @@ -12,6 +12,7 @@ export interface IProjectActivity timestamp: string; changes: IChangeEntity[]; metadata: ICommitMetadata; + changeTypes: string[]; changeName: 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 04ad20a31a..e4fe82c420 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 @@ -4,3 +4,7 @@ export * from './IProjectActivity' export * from './IProjectData' export * from './UserProjectRole' export * from './IChangeContext' +export * from './IActivityAuthor' +export * from './IActivityChangeType' +export * from './IActivityQuery' +export * from './ActivitySort' diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index 6e2fa465c7..2094bb516b 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -88,7 +88,7 @@
{#if record} - + {/if}
diff --git a/frontend/viewer/src/lib/services/history-service.ts b/frontend/viewer/src/lib/services/history-service.ts index e8f6bf163c..92da8474bf 100644 --- a/frontend/viewer/src/lib/services/history-service.ts +++ b/frontend/viewer/src/lib/services/history-service.ts @@ -1,4 +1,15 @@ -import type {IEntry, IExampleSentence, IHistoryLineItem, IProjectActivity, ISense} from '$lib/dotnet-types'; +import { + ActivitySort, + type IActivityAuthor, + type IActivityChangeType, + type IActivityQuery, + type IChangeContext, + type IEntry, + type IExampleSentence, + type IHistoryLineItem, + type IProjectActivity, + type ISense, +} from '$lib/dotnet-types'; import type { IHistoryServiceJsInvokable } from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable'; @@ -17,14 +28,11 @@ type EntityType = {entity: IEntry, entityName: 'Entry'} export type HistoryItem = IHistoryLineItem & EntityType; +export type {IActivityQuery, IActivityAuthor, IActivityChangeType}; + export class HistoryService { + get historyApi(): IHistoryServiceJsInvokable | undefined { - if (import.meta.env.DEV) { - //randomly return undefined to test fallback - if (Math.random() < 0.5) { - return undefined; - } - } return this.projectContext.historyService; } @@ -36,8 +44,8 @@ export class HistoryService { } async load(objectId: string) { - const data = await (this.historyApi?.getHistory(objectId) ?? fetch(`/api/history/${this.projectContext.projectCode}/${objectId}`) - .then(res => res.json())) as HistoryItem[]; + this.ensureLoaded(); + const data = await this.historyApi.getHistory(objectId); if (!Array.isArray(data)) { console.error('Invalid history data', data); return []; @@ -47,9 +55,8 @@ export class HistoryService { } async fetchSnapshot(history: HistoryItem, objectId: string): Promise { - const data = (await this.historyApi?.getObject(history.commitId, objectId) - ?? await fetch(`/api/history/${this.projectContext.projectCode}/snapshot/commit/${history.commitId}?entityId=${objectId}`) - .then(res => res.json())) as EntityType['entity']; + this.ensureLoaded(); + const data = (await this.historyApi.getObject(history.commitId, objectId)) as EntityType['entity']; if (isEntry(data)) { return {...history, entity: data, entityName: 'Entry'}; } @@ -62,17 +69,33 @@ export class HistoryService { throw new Error('Unable to determine type of object ' + JSON.stringify(data)); } - async activity(projectCode: string, skip: number, take: number): Promise { - return await (this.historyApi?.projectActivity(skip, take) - ?? fetch(`/api/activity/${projectCode}?skip=${skip}&take=${take}`).then(res => res.json())) as IProjectActivity[]; + async listActivityAuthors(): Promise { + this.ensureLoaded(); + return await this.historyApi.listActivityAuthors(); + } + + async listActivityChangeTypes(): Promise { + this.ensureLoaded(); + return await this.historyApi.listActivityChangeTypes(); + } + + async activity(skip: number, take: number, query?: IActivityQuery): Promise { + this.ensureLoaded(); + return await this.historyApi.projectActivity( + skip, + take, + query?.authorId, + query?.authorName, + query?.excludeFieldWorks ?? false, + query?.sort ?? ActivitySort.NewestFirst); } - loadChangeContext(commitId: string, changeIndex: number) { + async loadChangeContext(commitId: string, changeIndex: number): Promise { this.ensureLoaded(); - return this.projectContext.historyService!.loadChangeContext(commitId, changeIndex); + return this.historyApi.loadChangeContext(commitId, changeIndex); } - private ensureLoaded() { + private ensureLoaded(): asserts this is {loaded: true, historyApi: IHistoryServiceJsInvokable} { if (!this.loaded) { throw new Error('HistoryService not loaded'); } diff --git a/frontend/viewer/src/lib/services/service-provider-dotnet.ts b/frontend/viewer/src/lib/services/service-provider-dotnet.ts index 255ddd94cf..3aa4cec5c9 100644 --- a/frontend/viewer/src/lib/services/service-provider-dotnet.ts +++ b/frontend/viewer/src/lib/services/service-provider-dotnet.ts @@ -65,9 +65,14 @@ export function wrapInProxy(dotnetObject: DotNet.DotNetObj return async function proxyHandler(...args: unknown[]) { console.debug(`[Dotnet Proxy] Calling ${serviceName} method ${dotnetMethodName}`, args); args = transformArgs(args); - const result = await target.invokeMethodAsync(dotnetMethodName, ...args); - console.debug(`[Dotnet Proxy] ${serviceName} method ${dotnetMethodName} returned`, result); - return result; + try { + const result = await target.invokeMethodAsync(dotnetMethodName, ...args); + console.debug(`[Dotnet Proxy] ${serviceName} method ${dotnetMethodName} returned`, result); + return result; + } catch (error) { + console.error(`[Dotnet Proxy] ${serviceName} method ${dotnetMethodName} failed`, error); + throw error; + } }; }, }) as unknown as LexboxServiceRegistry[K]; diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index bd1f1f1831..fcde6dc486 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -185,6 +185,20 @@ msgstr "Add Word" msgid "All" msgstr "All" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "All authors" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "All change types" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -750,6 +764,12 @@ msgstr "Failed to copy to clipboard" msgid "Failed to download {0}" msgstr "Failed to download {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "Failed to load activity" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -977,6 +997,11 @@ msgstr "Headword" msgid "Hide" msgstr "Hide" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "Hide FieldWorks" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1091,6 +1116,12 @@ msgstr "List mode" msgid "Literal meaning" msgstr "Literal meaning" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "Loading activity" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1261,13 +1292,19 @@ msgstr "New Entry" msgid "New Word" msgstr "New Word" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "Newest first" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Next" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "No activity found" +msgid "No activity matches these filters" +msgstr "No activity matches these filters" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1389,6 +1426,11 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, unable to download" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "Oldest first" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1806,6 +1848,16 @@ msgstr "Sync your changes with other FieldWorks Lite users" msgid "Synced" msgstr "Synced" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "Synced newest" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "Synced oldest" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1979,7 +2031,11 @@ msgid "Unable to open in FieldWorks" msgstr "Unable to open in FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 46615ced36..3157e21a2b 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -190,6 +190,20 @@ msgstr "Añadir palabra" msgid "All" msgstr "Todos" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "Error al copiar al portapapeles" msgid "Failed to download {0}" msgstr "Error al descargar {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "Palabra clave" msgid "Hide" msgstr "Ocultar" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "Modo lista" msgid "Literal meaning" msgstr "Significado literal" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "Nueva entrada" msgid "New Word" msgstr "Nueva palabra" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Siguiente" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "No se ha encontrado actividad" +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "Fuera de línea" msgid "Offline, unable to download" msgstr "Desconectado, no se puede descargar" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "Sincroniza tus cambios con otros usuarios de FieldWorks Lite" msgid "Synced" msgstr "Sincronizado" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "No se puede abrir en FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 0ec8860ed1..b8857c509e 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -190,6 +190,20 @@ msgstr "Ajouter un mot" msgid "All" msgstr "Tous" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "Copie dans le presse-papiers échouée" msgid "Failed to download {0}" msgstr "Téléchargement échoué {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "Entrée de dictionnaire" msgid "Hide" msgstr "Cacher" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "Mode liste" msgid "Literal meaning" msgstr "Sens littéral" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "Nouvelle entrée" msgid "New Word" msgstr "Nouveau mot" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Suivant" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "Aucune activité trouvée" +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "Déconnecté" msgid "Offline, unable to download" msgstr "Déconnecté, impossible de télécharger" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "Synchronisez vos modifications avec d'autres utilisateurs FieldWorks Lit msgid "Synced" msgstr "Synchronisé" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "Impossible d'ouvrir FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 72a64bad67..6a6cbc7b90 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -190,6 +190,20 @@ msgstr "Tambahkan Kata" msgid "All" msgstr "Semua" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "Gagal menyalin ke papan klip" msgid "Failed to download {0}" msgstr "Gagal mengunduh {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "Headword" msgid "Hide" msgstr "Sembunyikan" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "Mode daftar" msgid "Literal meaning" msgstr "Arti harfiah" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "Entri Baru" msgid "New Word" msgstr "Kata Baru" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Berikutnya" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "Tidak ada aktivitas yang ditemukan" +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "Offline" msgid "Offline, unable to download" msgstr "Offline, tidak dapat mengunduh" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "Sinkronkan perubahan Anda dengan pengguna FieldWorks Lite lainnya" msgid "Synced" msgstr "Disinkronkan" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "Tidak dapat dibuka di FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index 3bae6a75f7..eab3e5fb67 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -190,6 +190,20 @@ msgstr "단어 추가" msgid "All" msgstr "모두" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "클립보드에 복사하지 못했습니다." msgid "Failed to download {0}" msgstr "다운로드에 실패했습니다 {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "헤드워드" msgid "Hide" msgstr "숨기기" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "목록 모드" msgid "Literal meaning" msgstr "문자 그대로의 의미" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "새 항목" msgid "New Word" msgstr "새 단어" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "다음" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "활동을 찾을 수 없습니다." +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "오프라인" msgid "Offline, unable to download" msgstr "오프라인 상태, 다운로드할 수 없음" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "다른 FieldWorks Lite 사용자와 변경 사항 동기화" msgid "Synced" msgstr "동기화" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "FieldWorks에서 열 수 없음" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 1da95247c2..305e9745ab 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -190,6 +190,20 @@ msgstr "Tambah Perkataan" msgid "All" msgstr "Semua" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "Gagal menyalin ke papan keratan" msgid "Failed to download {0}" msgstr "Gagal memuat turun {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "Kata Utama" msgid "Hide" msgstr "Sembunyi" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "Mod senarai" msgid "Literal meaning" msgstr "Makna harfiah" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "Entri Baru" msgid "New Word" msgstr "Perkataan Baru" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Seterusnya" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "Tiada aktiviti ditemui" +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "Luar talian" msgid "Offline, unable to download" msgstr "Luar talian, tidak dapat memuat turun" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "Segerakkan perubahan anda dengan pengguna FieldWorks Lite yang lain" msgid "Synced" msgstr "Disegerakkan" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "Tidak dapat membuka dalam FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index bdf4557e1e..a07e37c388 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -190,6 +190,20 @@ msgstr "Ongeza Neno" msgid "All" msgstr "Vyote" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "Imeshindwa kunakili kwenye ubao wa kunakili" msgid "Failed to download {0}" msgstr "Imeshindwa kupakua {0}" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "Neno la kichwa" msgid "Hide" msgstr "Ficha" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "Hali ya orodha" msgid "Literal meaning" msgstr "Maana halisi" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "Ingizo Mpya" msgid "New Word" msgstr "Neno Jipya" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Inayofuata" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "Hakuna shughuli iliyopatikana" +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "Nyuma ya mtandao" msgid "Offline, unable to download" msgstr "Nyuma ya mtandao, haiwezi kupakua" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "Kuoanisha mabadiliko yako na watumiaji wengine wa FieldWorks Lite" msgid "Synced" msgstr "Kuoanishwa" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "Haiwezi kufungua katika FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index b8d92186c3..1cefefb2c9 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -190,6 +190,20 @@ msgstr "Thêm Từ" msgid "All" msgstr "Tất cả" +#. Filter option in activity view author dropdown — show commits from every author. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All authors" +msgstr "" + +#. Filter option in activity view change-type dropdown — show all kinds of edits. +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +msgid "All change types" +msgstr "" + #. Relevant view: Classic #. Lite view equivalent: "a word" #. Placeholder/suggestion in search field @@ -755,6 +769,12 @@ msgstr "Sao chép vào khay nhớ tạm thất bại" msgid "Failed to download {0}" msgstr "Tải xuống {0} thất bại" +#. Error message when the activity feed fails to load (toast and empty state). +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Failed to load activity" +msgstr "" + #. Error message when entries fail to load #: src/project/browse/EntriesList.svelte #: src/project/browse/EntriesList.svelte @@ -982,6 +1002,11 @@ msgstr "Từ đầu mục" msgid "Hide" msgstr "Ẩn" +#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). +#: src/lib/activity/ActivityFilter.svelte +msgid "Hide FieldWorks" +msgstr "" + #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1121,12 @@ msgstr "Chế độ danh sách" msgid "Literal meaning" msgstr "Ý nghĩa đen" +#. Accessible label on the activity view loading spinner while filtered commits are fetched. +#: src/lib/activity/ActivityView.svelte +#: src/lib/activity/ActivityView.svelte +msgid "Loading activity" +msgstr "" + #. Loading state text shown in the project/dictionary dropdown while the list of dictionaries is being fetched. #: src/project/ProjectDropdown.svelte msgid "Loading Dictionaries..." @@ -1266,13 +1297,19 @@ msgstr "Mục mới" msgid "New Word" msgstr "Từ mới" +#. Sort option in activity view — most recent commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Newest first" +msgstr "" + #: src/project/tasks/SubjectPopup.svelte msgid "Next" msgstr "Tiếp theo" +#. Empty state when activity list filters exclude every commit. #: src/lib/activity/ActivityView.svelte -msgid "No activity found" -msgstr "Không tìm thấy hoạt động" +msgid "No activity matches these filters" +msgstr "" #. Placeholder when no audio file #. Shows in audio field when empty (no file uploaded yet) @@ -1394,6 +1431,11 @@ msgstr "Ngoại tuyến" msgid "Offline, unable to download" msgstr "Ngoại tuyến, không thể tải xuống" +#. Sort option in activity view — earliest commit first. +#: src/lib/activity/ActivityFilter.svelte +msgid "Oldest first" +msgstr "" + #. Explanation of sync behavior #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "One FieldWorks Classic commit may consist of changes to multiple entries or fields. On the other hand, a commit may only affect data that is not synced to FieldWorks Lite." @@ -1811,6 +1853,16 @@ msgstr "Đồng bộ các thay đổi của bạn với người dùng FieldWork msgid "Synced" msgstr "Đã đồng bộ" +#. Sort option in activity view — by upload/download time, newest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced newest" +msgstr "" + +#. Sort option in activity view — by upload/download time, oldest synced first (unsynced commits last). +#: src/lib/activity/ActivityFilter.svelte +msgid "Synced oldest" +msgstr "" + #. Status showing sync source server #: src/home/HomeView.svelte msgid "Synced with {0}" @@ -1984,7 +2036,11 @@ msgid "Unable to open in FieldWorks" msgstr "Không thể mở trong FieldWorks" #. Fallback value shown when author name or last-change date is unavailable (e.g., in activity history or the sync panel). +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte +#: src/lib/activity/ActivityFilter.svelte #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown" From af3ab1eea5a551444e0798e9d06b5c983c190133 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 16 Jun 2026 11:11:39 +0700 Subject: [PATCH 02/10] fix lint error --- frontend/viewer/src/lib/services/history-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/services/history-service.ts b/frontend/viewer/src/lib/services/history-service.ts index 92da8474bf..88ece69a0b 100644 --- a/frontend/viewer/src/lib/services/history-service.ts +++ b/frontend/viewer/src/lib/services/history-service.ts @@ -45,7 +45,7 @@ export class HistoryService { async load(objectId: string) { this.ensureLoaded(); - const data = await this.historyApi.getHistory(objectId); + const data = await this.historyApi.getHistory(objectId) as HistoryItem[]; if (!Array.isArray(data)) { console.error('Invalid history data', data); return []; From 6c60a50d816ed7840199f2e767b2a10a53fca9a8 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 16 Jun 2026 11:15:31 +0700 Subject: [PATCH 03/10] remove an invalid css style, update a generated type --- frontend/viewer/src/lib/activity/ActivityView.svelte | 2 +- .../dotnet-types/generated-types/LcmCrdt/IProjectActivity.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index 4b442c4f0e..b26cb349cc 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -128,7 +128,7 @@
+ style="grid-template-rows: auto minmax(0,100%); grid-template-columns: 1fr 2fr">
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 52e2e03c30..ad0681ad65 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 @@ -12,7 +12,7 @@ export interface IProjectActivity timestamp: string; changes: IChangeEntity[]; metadata: ICommitMetadata; - changeTypes: string[]; changeName: string; + changeTypes: string[]; } /* eslint-enable */ From aeb4fb818fdff2fb1f51444f732c396a9efab58c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 18 Jun 2026 10:07:18 +0700 Subject: [PATCH 04/10] Support multiselect activity filters --- .../Services/HistoryServiceJsInvokable.cs | 7 +- .../FwLite/FwLiteWeb/Routes/ActivityRoutes.cs | 7 +- .../HistoryServiceActivityTests.cs | 29 +++++- backend/FwLite/LcmCrdt/HistoryService.cs | 60 ++++++++---- .../src/lib/activity/ActivityFilter.svelte | 93 +++++++++++++++---- .../src/lib/activity/ActivityView.svelte | 19 ++-- frontend/viewer/src/lib/activity/utils.ts | 50 ++++++---- .../components/ui/select/select-item.svelte | 12 ++- .../Services/IHistoryServiceJsInvokable.ts | 2 +- .../generated-types/LcmCrdt/IActivityQuery.ts | 5 +- .../src/lib/services/history-service.ts | 7 +- 11 files changed, 210 insertions(+), 81 deletions(-) diff --git a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs index 24b8e5cc25..5921709ec6 100644 --- a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs @@ -17,13 +17,12 @@ public Task GetObject(Guid commitId, Guid entityId) public async ValueTask ProjectActivity( int skip, int take, - string? authorId = null, - string? authorName = null, - bool excludeFieldWorks = false, + string[]? authorFilterKeys = null, + string[]? changeTypeKeys = null, ActivitySort sort = ActivitySort.NewestFirst) { return await historyService.ProjectActivity(skip, take, - new ActivityQuery(authorId, authorName, excludeFieldWorks, sort)).ToArrayAsync(); + new ActivityQuery(authorFilterKeys, changeTypeKeys, sort)).ToArrayAsync(); } [JSInvokable] diff --git a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs index 7c5f197fef..09ecf842dd 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs @@ -22,11 +22,10 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) HistoryService historyService, int skip = 0, int take = 100, - string? authorId = null, - string? authorName = null, - bool excludeFieldWorks = false, + string[]? authorFilterKeys = null, + string[]? changeTypeKeys = null, ActivitySort sort = ActivitySort.NewestFirst) => - historyService.ProjectActivity(skip, take, new ActivityQuery(authorId, authorName, excludeFieldWorks, sort))); + historyService.ProjectActivity(skip, take, new ActivityQuery(authorFilterKeys, changeTypeKeys, sort))); group.MapGet("/authors", (HistoryService historyService) => historyService.ListActivityAuthors()); group.MapGet("/change-types", (HistoryService historyService) => historyService.ListActivityChangeTypes()); return group; diff --git a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs index 8e20f32026..376395fec8 100644 --- a/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -56,19 +56,19 @@ 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(AuthorId: "alice-id")).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); activities.Should().OnlyContain(a => a.Metadata.AuthorId == "alice-id"); activities.Should().HaveCountGreaterThanOrEqualTo(1); } [Fact] - public async Task ProjectActivity_ExcludeFieldWorks_HidesFieldWorksCommits() + 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(ExcludeFieldWorks: true)).ToArrayAsync(); + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); activities.Should().NotContain(a => a.Metadata.AuthorName == "FieldWorks"); activities.Should().Contain(a => a.Metadata.AuthorName == "Alice"); @@ -110,12 +110,33 @@ 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(AuthorId: "alice-id")).ToArrayAsync(); + var page = await Service.ProjectActivity(0, 1, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); page.Should().HaveCount(1); page[0].Metadata.AuthorId.Should().Be("alice-id"); } + [Fact] + public async Task ProjectActivity_FiltersByChangeTypeKeys() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: ["CreateEntryChange"])).ToArrayAsync(); + + activities.Should().OnlyContain(a => a.ChangeTypes.Contains("CreateEntryChange")); + activities.Should().HaveCountGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task ProjectActivity_ChangeTypeKeys_FiltersMultipleTypes() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(ChangeTypeKeys: ["CreateEntryChange", "MissingType"])).ToArrayAsync(); + + activities.Should().OnlyContain(a => a.ChangeTypes.Any(t => t == "CreateEntryChange")); + } + [Fact] public async Task ProjectActivity_IncludesChangeTypes() { diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index e83aa86307..9d039a65c8 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -26,11 +26,16 @@ public enum ActivitySort } public record ActivityQuery( - string? AuthorId = null, - string? AuthorName = null, - bool ExcludeFieldWorks = false, + string[]? AuthorFilterKeys = null, + string[]? ChangeTypeKeys = null, ActivitySort Sort = ActivitySort.NewestFirst); +public static class ActivityFilterKeys +{ + public const string UnknownAuthor = "__unknown__"; + public const string AuthorNamePrefix = "name:"; +} + public record ProjectActivity( Guid CommitId, DateTimeOffset Timestamp, @@ -135,7 +140,7 @@ public async IAsyncEnumerable ProjectActivity(int skip = 0, int query ??= new ActivityQuery(); await using ICrdtDbContext dbContext = await dbContextFactory.CreateDbContextAsync(); var changeEntities = dbContext.Set>(); - var commits = ApplyActivityFilters(dbContext.Commits, query); + var commits = ApplyActivityFilters(dbContext.Commits, changeEntities, query); commits = ApplyActivitySort(commits, query.Sort); var queryable = from commit in commits.Skip(skip).Take(take) @@ -151,29 +156,52 @@ on commit.Id equals changeEntity.CommitId into changes } } - private static IQueryable ApplyActivityFilters(IQueryable commits, ActivityQuery query) + private static IQueryable ApplyActivityFilters( + IQueryable commits, + IQueryable> changeEntities, + ActivityQuery query) { - if (query.ExcludeFieldWorks) + if (query.AuthorFilterKeys is { Length: 0 }) { - commits = commits.ToLinqToDB().Where(c => - (Json.Value(c.Metadata, m => m.AuthorName) ?? "") != "FieldWorks"); + return commits.ToLinqToDB().Where(_ => false); } - if (query.AuthorId == "") + if (query.AuthorFilterKeys is { Length: > 0 }) { + var authorIds = new List(); + var authorNames = new List(); + var includeUnknown = false; + foreach (var key in query.AuthorFilterKeys) + { + if (key == ActivityFilterKeys.UnknownAuthor) + includeUnknown = true; + else if (key.StartsWith(ActivityFilterKeys.AuthorNamePrefix, StringComparison.Ordinal)) + authorNames.Add(key[ActivityFilterKeys.AuthorNamePrefix.Length..]); + else + authorIds.Add(key); + } + commits = commits.ToLinqToDB().Where(c => - (Json.Value(c.Metadata, m => m.AuthorId) ?? "") == "" - && (Json.Value(c.Metadata, m => m.AuthorName) ?? "") == ""); + (includeUnknown + && (Json.Value(c.Metadata, m => m.AuthorId) ?? "") == "" + && (Json.Value(c.Metadata, m => m.AuthorName) ?? "") == "") + || authorIds.Contains(Json.Value(c.Metadata, m => m.AuthorId) ?? "") + || authorNames.Contains(Json.Value(c.Metadata, m => m.AuthorName) ?? "")); } - else if (query.AuthorId is not null) + + if (query.ChangeTypeKeys is { Length: 0 }) { - commits = commits.ToLinqToDB().Where(c => - Json.Value(c.Metadata, m => m.AuthorId) == query.AuthorId); + return commits.ToLinqToDB().Where(_ => false); } - else if (query.AuthorName is not null) + + if (query.ChangeTypeKeys is { Length: > 0 }) { + var changeTypeKeys = query.ChangeTypeKeys; commits = commits.ToLinqToDB().Where(c => - Json.Value(c.Metadata, m => m.AuthorName) == query.AuthorName); + changeEntities.ToLinqToDB().Any(ce => + ce.CommitId == c.Id + && changeTypeKeys.Contains(Sql.Expr( + "json_extract({0}, '$.\"$type\"')", ce.Change)))); } return commits; diff --git a/frontend/viewer/src/lib/activity/ActivityFilter.svelte b/frontend/viewer/src/lib/activity/ActivityFilter.svelte index 13e9d78bc6..4a5c9ec66a 100644 --- a/frontend/viewer/src/lib/activity/ActivityFilter.svelte +++ b/frontend/viewer/src/lib/activity/ActivityFilter.svelte @@ -6,16 +6,22 @@ import {ActivitySort} from '$lib/dotnet-types'; import * as Select from '$lib/components/ui/select'; import * as ResponsiveMenu from '$lib/components/responsive-menu'; - import {Switch} from '$lib/components/ui/switch'; import {Button, buttonVariants} from '$lib/components/ui/button'; + import {badgeVariants} from '$lib/components/ui/badge'; import {cn} from '$lib/utils'; + import {Icon} from '$lib/components/ui/icon'; + import type {IconClass} from '$lib/icon-class'; import { ALL_AUTHORS, ALL_CHANGE_TYPES, UNKNOWN_AUTHOR, + applyMultiSelectValue, authorFilterKey, createDefaultActivityFilters, + isAllFilterSelection, + resolveFilterKeys, type ActivityFilters, + type MultiFilterSelection, } from './utils'; type Props = { @@ -32,7 +38,7 @@ if (!loaded) return []; const data = await historyService.listActivityAuthors(); return Array.isArray(data) ? data : []; - }, + }, {initialValue: []}, ); @@ -46,29 +52,71 @@ {initialValue: []}, ); + const authorKeys = $derived(authors.current.map(authorFilterKey)); + const changeTypeKeys = $derived(changeTypes.current.map(ct => ct.key)); + + const authorSelectValue = $derived(resolveFilterKeys(filters.authorFilterKeys, authorKeys)); + const changeTypeSelectValue = $derived(resolveFilterKeys(filters.changeTypeFilterKeys, changeTypeKeys)); + const sortLabels = $derived>({ [ActivitySort.NewestFirst]: $t`Newest first`, [ActivitySort.OldestFirst]: $t`Oldest first`, [ActivitySort.SyncedNewestFirst]: $t`Synced newest`, [ActivitySort.SyncedOldestFirst]: $t`Synced oldest`, }); + + const sortIcons: Record = { + [ActivitySort.NewestFirst]: 'i-mdi-sort-clock-descending', + [ActivitySort.OldestFirst]: 'i-mdi-sort-clock-ascending', + [ActivitySort.SyncedNewestFirst]: 'i-mdi-cloud-arrow-down', + [ActivitySort.SyncedOldestFirst]: 'i-mdi-cloud-arrow-up', + }; + + function authorLabel(key: string): string { + if (key === UNKNOWN_AUTHOR) return $t`Unknown`; + const author = authors.current.find(a => authorFilterKey(a) === key); + return author?.authorName ?? key; + } + + function allSelectionIcon(selected: MultiFilterSelection, allKeys: string[]): IconClass | undefined { + if (selected === 'all' || isAllFilterSelection(selected, allKeys)) return 'i-mdi-check'; + if (selected.length === 0) return undefined; + return 'i-mdi-minus'; + } + + function onAuthorValueChange(value: string[]) { + filters.authorFilterKeys = applyMultiSelectValue(value, authorKeys, ALL_AUTHORS, filters.authorFilterKeys); + } + + function onChangeTypeValueChange(value: string[]) { + filters.changeTypeFilterKeys = applyMultiSelectValue(value, changeTypeKeys, ALL_CHANGE_TYPES, filters.changeTypeFilterKeys); + }
- filters.authorFilter = v}> + - {#if filters.authorFilter === ALL_AUTHORS} + {#if isAllFilterSelection(filters.authorFilterKeys, authorKeys)} {$t`All authors`} - {:else if filters.authorFilter === UNKNOWN_AUTHOR} - {$t`Unknown`} + {:else if filters.authorFilterKeys.length === 0} + {$t`No authors`} + {:else if filters.authorFilterKeys.length === 1} + {authorLabel(filters.authorFilterKeys[0])} {:else} - {@const author = authors.current.find(a => authorFilterKey(a) === filters.authorFilter)} - {author?.authorName ?? filters.authorFilter} + {$t`${filters.authorFilterKeys.length} authors`} {/if} - {$t`All authors`} + + {#snippet selectedIndicator()} + {@const icon = allSelectionIcon(filters.authorFilterKeys, authorKeys)} + {#if icon} + + {/if} + {/snippet} + {$t`All authors`} + {#each authors.current as author (authorFilterKey(author))} {@const key = authorFilterKey(author)} @@ -79,16 +127,28 @@ - filters.changeTypeFilter = v}> + - {#if filters.changeTypeFilter === ALL_CHANGE_TYPES} + {#if isAllFilterSelection(filters.changeTypeFilterKeys, changeTypeKeys)} {$t`All change types`} + {:else if filters.changeTypeFilterKeys.length === 0} + {$t`No change types`} + {:else if filters.changeTypeFilterKeys.length === 1} + {changeTypes.current.find(ct => ct.key === filters.changeTypeFilterKeys[0])?.label ?? filters.changeTypeFilterKeys[0]} {:else} - {changeTypes.current.find(ct => ct.key === filters.changeTypeFilter)?.label ?? filters.changeTypeFilter} + {$t`${filters.changeTypeFilterKeys.length} change types`} {/if} - {$t`All change types`} + + {#snippet selectedIndicator()} + {@const icon = allSelectionIcon(filters.changeTypeFilterKeys, changeTypeKeys)} + {#if icon} + + {/if} + {/snippet} + {$t`All change types`} + {#each changeTypes.current as changeType (changeType.key)} {changeType.label} @@ -98,12 +158,10 @@ - - - + {#snippet child({props})} - {/snippet} @@ -113,6 +171,7 @@ filters.sort = sortOption} class={cn(filters.sort === sortOption && 'bg-muted')}> + {sortLabels[sortOption]} {/each} diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index b26cb349cc..77ebb8c20d 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -12,11 +12,10 @@ import {AppNotification} from '$lib/notifications/notifications'; import type {IProjectActivity} from '$lib/dotnet-types'; import { - ALL_CHANGE_TYPES, - MIN_VISIBLE_FILTERED, createDefaultActivityFilters, emptyActivityLoad, - filterActivityByChangeType, + hasActiveServerFilters, + MIN_VISIBLE_FILTERED, serverQueryKey, toServerQuery, type ActivityFilters, @@ -82,7 +81,7 @@ if (awaitingFreshData) { return null; } - return filterActivityByChangeType(activity.current?.items ?? [], filters.changeTypeFilter); + return activity.current?.items ?? []; }); let selectedRow = $state(); @@ -100,7 +99,7 @@ }); $effect(() => { - if (filters.changeTypeFilter === ALL_CHANGE_TYPES || activity.loading || visibleActivity === null || !hasMorePages) return; + if (!hasActiveServerFilters(filters) || activity.loading || visibleActivity === null || !hasMorePages) return; const filtered = visibleActivity.length; const loaded = activity.current?.items.length ?? 0; if (filtered < MIN_VISIBLE_FILTERED && loaded >= (pageCount - 1) * BATCH_SIZE && loaded > 0) { @@ -158,8 +157,14 @@ selected={selectedRow?.commitId === row.commitId} class="mb-2"> {row.changeName} -
- +
+ + {#if !row.metadata.extraMetadata['SyncDate']} + + {/if} diff --git a/frontend/viewer/src/lib/activity/utils.ts b/frontend/viewer/src/lib/activity/utils.ts index 4646297c27..7edbe28bf6 100644 --- a/frontend/viewer/src/lib/activity/utils.ts +++ b/frontend/viewer/src/lib/activity/utils.ts @@ -17,26 +17,34 @@ export const emptyActivityLoad: ActivityLoad = { queryKey: '', }; +export type MultiFilterSelection = string[] | 'all'; + export type ActivityFilters = { - authorFilter: string; - changeTypeFilter: string; - excludeFieldWorks: boolean; + authorFilterKeys: MultiFilterSelection; + changeTypeFilterKeys: MultiFilterSelection; sort: ActivitySort; }; export function createDefaultActivityFilters(): ActivityFilters { return { - authorFilter: ALL_AUTHORS, - changeTypeFilter: ALL_CHANGE_TYPES, - excludeFieldWorks: false, + authorFilterKeys: 'all', + changeTypeFilterKeys: 'all', sort: ActivitySort.NewestFirst, }; } +export function isAllFilterSelection(selected: MultiFilterSelection, allKeys: string[]): boolean { + return selected === 'all' || (allKeys.length > 0 && selected.length === allKeys.length && allKeys.every(k => selected.includes(k))); +} + +export function resolveFilterKeys(selected: MultiFilterSelection, allKeys: string[]): string[] { + return selected === 'all' ? allKeys : selected; +} + export function toServerQuery(filters: ActivityFilters): IActivityQuery { return { - ...parseAuthorFilter(filters.authorFilter), - excludeFieldWorks: filters.excludeFieldWorks, + authorFilterKeys: filters.authorFilterKeys === 'all' ? undefined : filters.authorFilterKeys, + changeTypeKeys: filters.changeTypeFilterKeys === 'all' ? undefined : filters.changeTypeFilterKeys, sort: filters.sort, }; } @@ -59,17 +67,21 @@ export function authorFilterKey(author: IActivityAuthor): string { return `name:${author.authorName}`; } -export function parseAuthorFilter(key: string): Pick { - if (key === ALL_AUTHORS) return {}; - if (key === UNKNOWN_AUTHOR) return {authorId: ''}; - if (key.startsWith('name:')) return {authorName: key.slice(5)}; - return {authorId: key}; +export function applyMultiSelectValue( + value: string[], + allKeys: string[], + allKey: string, + currentSelection: MultiFilterSelection, +): MultiFilterSelection { + if (value.includes(allKey)) { + return isAllFilterSelection(currentSelection, allKeys) ? [] : 'all'; + } + if (isAllFilterSelection(value, allKeys)) { + return 'all'; + } + return value; } -export function filterActivityByChangeType( - activities: IProjectActivity[], - changeTypeKey: string, -): IProjectActivity[] { - if (changeTypeKey === ALL_CHANGE_TYPES) return activities; - return activities.filter(a => a.changeTypes?.includes(changeTypeKey)); +export function hasActiveServerFilters(filters: ActivityFilters): boolean { + return filters.authorFilterKeys !== 'all' || filters.changeTypeFilterKeys !== 'all'; } diff --git a/frontend/viewer/src/lib/components/ui/select/select-item.svelte b/frontend/viewer/src/lib/components/ui/select/select-item.svelte index cc2e55a4a1..59f1457cf8 100644 --- a/frontend/viewer/src/lib/components/ui/select/select-item.svelte +++ b/frontend/viewer/src/lib/components/ui/select/select-item.svelte @@ -2,6 +2,11 @@ import {Icon} from '../icon'; import {Select as SelectPrimitive} from 'bits-ui'; import {cn, type WithoutChild} from '$lib/utils.js'; + import type {Snippet} from 'svelte'; + + type Props = WithoutChild & { + selectedIndicator?: Snippet; + }; let { ref = $bindable(null), @@ -9,8 +14,9 @@ value, label, children: childrenProp, + selectedIndicator, ...restProps - }: WithoutChild = $props(); + }: Props = $props(); {#snippet children({selected, highlighted})} - {#if selected} + {#if selectedIndicator} + {@render selectedIndicator()} + {:else if selected} {/if} diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts index c003d92f07..0c4ed16372 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable.ts @@ -15,7 +15,7 @@ import type {IChangeContext} from '../../LcmCrdt/IChangeContext'; export interface IHistoryServiceJsInvokable { getObject(commitId: string, entityId: string) : Promise; - projectActivity(skip: number, take: number, authorId?: string, authorName?: string, excludeFieldWorks?: boolean, sort?: ActivitySort) : Promise; + projectActivity(skip: number, take: number, authorFilterKeys?: string[], changeTypeKeys?: string[], sort?: ActivitySort) : Promise; listActivityAuthors() : Promise; listActivityChangeTypes() : Promise; getSnapshot(snapshotId: string) : Promise; 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 e3a1e50828..686a24fa44 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 @@ -7,9 +7,8 @@ import type {ActivitySort} from './ActivitySort'; export interface IActivityQuery { - authorId?: string; - authorName?: string; - excludeFieldWorks: boolean; + authorFilterKeys?: string[]; + changeTypeKeys?: string[]; sort: ActivitySort; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/services/history-service.ts b/frontend/viewer/src/lib/services/history-service.ts index 88ece69a0b..2df3785633 100644 --- a/frontend/viewer/src/lib/services/history-service.ts +++ b/frontend/viewer/src/lib/services/history-service.ts @@ -83,10 +83,9 @@ export class HistoryService { this.ensureLoaded(); return await this.historyApi.projectActivity( skip, - take, - query?.authorId, - query?.authorName, - query?.excludeFieldWorks ?? false, + take, + query?.authorFilterKeys, + query?.changeTypeKeys, query?.sort ?? ActivitySort.NewestFirst); } From 3b80b7080ac9d58a1cd71efe7501811eb1549d36 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 18 Jun 2026 10:19:01 +0700 Subject: [PATCH 05/10] Scope activity toolbar to list column --- frontend/viewer/src/lib/activity/ActivityView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index 77ebb8c20d..c32382f55f 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -129,7 +129,7 @@
-
+
{#if loading.current} From 466b0402541344d85874bf40c78900a90ab0d459 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 18 Jun 2026 10:19:33 +0700 Subject: [PATCH 06/10] update locales files --- frontend/viewer/src/locales/en.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/es.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/fr.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/id.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/ko.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/ms.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/sw.po | 22 +++++++++++++++++----- frontend/viewer/src/locales/vi.po | 22 +++++++++++++++++----- 8 files changed, 136 insertions(+), 40 deletions(-) diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index fcde6dc486..5f9387418a 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -43,6 +43,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} ago" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "{0} authors" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "{0} change types" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -997,11 +1005,6 @@ msgstr "Headword" msgid "Hide" msgstr "Hide" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "Hide FieldWorks" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1312,6 +1315,14 @@ msgstr "No activity matches these filters" msgid "No audio" msgstr "No audio" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "No authors" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "No change types" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1944,6 +1955,7 @@ msgstr "The time when you uploaded or downloaded these changes" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 3157e21a2b..574e759cd0 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} hace" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "Palabra clave" msgid "Hide" msgstr "Ocultar" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "Sin audio" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "El tiempo cuando subiste o descargaste estos cambios" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "Estos cambios no se han subido todavía. Asegúrate de que estás conectado e inicia sesión para compartir tus cambios." diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index b8857c509e..6a02d297a2 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "il y a {0}" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "Entrée de dictionnaire" msgid "Hide" msgstr "Cacher" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "Aucun contenu audio" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "L'heure où vous avez téléchargé ou téléchargé ces modifications" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "Ces modifications n'ont pas encore été téléchargées. Assurez-vous que vous êtes connecté et que vous êtes connecté pour partager vos modifications." diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 6a6cbc7b90..91a521112b 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} yang lalu" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "Headword" msgid "Hide" msgstr "Sembunyikan" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "Tidak ada audio" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "Waktu ketika Anda mengunggah atau mengunduh perubahan ini" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "Perubahan ini belum diunggah. Pastikan Anda online dan masuk untuk membagikan perubahan Anda." diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index eab3e5fb67..fa8afc57ea 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} 전" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "헤드워드" msgid "Hide" msgstr "숨기기" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "오디오 없음" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "이러한 변경 사항을 업로드하거나 다운로드한 시간" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "변경 내용이 아직 업로드되지 않았습니다. 변경 내용을 공유하려면 온라인 상태이고 로그인했는지 확인하세요." diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index 305e9745ab..e3e637a67a 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} yang lalu" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "Kata Utama" msgid "Hide" msgstr "Sembunyi" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "Tiada audio" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "Masa apabila anda memuat naik atau memuat turun perubahan ini" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "Perubahan ini belum dimuat naik. Pastikan anda dalam talian dan log masuk untuk berkongsi perubahan anda." diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index a07e37c388..47e58e173b 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} iliyopita" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "Neno la kichwa" msgid "Hide" msgstr "Ficha" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "Hakuna sauti" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "Wakati ulipoweka au kupakua mabadiliko haya" #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "Mabadiliko haya hayajawekwa bado. Hakikisha unaunganishwa na kuingia ili kushiriki mabadiliko yako." diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 1cefefb2c9..f2fb8ac91f 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -48,6 +48,14 @@ msgstr "{0} (FieldWorks)" msgid "{0} ago" msgstr "{0} trước" +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "{0} change types" +msgstr "" + #. File size display with unit #: src/lib/components/audio/audio-editor.svelte msgid "{0} MB" @@ -1002,11 +1010,6 @@ msgstr "Từ đầu mục" msgid "Hide" msgstr "Ẩn" -#. Toggle in activity view to hide commits imported from FieldWorks (author name "FieldWorks"). -#: src/lib/activity/ActivityFilter.svelte -msgid "Hide FieldWorks" -msgstr "" - #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1317,6 +1320,14 @@ msgstr "" msgid "No audio" msgstr "Không có âm thanh" +#: src/lib/activity/ActivityFilter.svelte +msgid "No authors" +msgstr "" + +#: src/lib/activity/ActivityFilter.svelte +msgid "No change types" +msgstr "" + #. Empty state message in the manage custom views dialog when none exist. #: src/lib/views/custom/ManageCustomViewsDialog.svelte msgid "No custom views yet." @@ -1949,6 +1960,7 @@ msgstr "Thời gian bạn đã tải lên hoặc tải xuống các thay đổi #. Error message when changes are pending upload #: src/lib/activity/ActivityItem.svelte +#: src/lib/activity/ActivityView.svelte msgid "These changes have not been uploaded yet. Ensure you're online and logged in to share your changes." msgstr "Những thay đổi này chưa được tải lên. Hãy đảm bảo bạn đang trực tuyến và đã đăng nhập để chia sẻ các thay đổi." From a99aca58d316ff937ec9f1e4f3544a0844a44368 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 18 Jun 2026 11:16:15 +0700 Subject: [PATCH 07/10] ensure the sort option is on it's own line to match the entry list --- frontend/viewer/src/lib/activity/ActivityFilter.svelte | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/lib/activity/ActivityFilter.svelte b/frontend/viewer/src/lib/activity/ActivityFilter.svelte index 4a5c9ec66a..7edef107db 100644 --- a/frontend/viewer/src/lib/activity/ActivityFilter.svelte +++ b/frontend/viewer/src/lib/activity/ActivityFilter.svelte @@ -93,7 +93,8 @@ } -
+
+
@@ -157,8 +158,9 @@ {/each} - - +
+
+ {#snippet child({props})}
From 05e3ba77f589abccf0403555dafd687974d2e43a Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 18 Jun 2026 13:10:21 +0700 Subject: [PATCH 08/10] Add history view integration to activity items - Introduced a "History" button in `ActivityItem` to display related history entries. - Enhanced `HistoryView` with support for selecting a specific commit. - Updated HistoryView.svelte to svelte 5 --- .../src/lib/activity/ActivityItem.svelte | 18 ++++++- .../src/lib/activity/ActivityView.svelte | 2 +- .../viewer/src/lib/history/HistoryView.svelte | 53 ++++++++++++------- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/frontend/viewer/src/lib/activity/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 852e943092..2edae109bd 100644 --- a/frontend/viewer/src/lib/activity/ActivityItem.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItem.svelte @@ -29,18 +29,23 @@ import type {HTMLAttributes} from 'svelte/elements'; import {cn} from '$lib/utils'; import * as Popover from '$lib/components/ui/popover'; + import {Button} from '$lib/components/ui/button'; + import HistoryView from '$lib/history/HistoryView.svelte'; type Props = HTMLAttributes & { activity: IProjectActivity; + showHistoryButton?: boolean; } const { activity, + showHistoryButton = false, class: className, ...restProps }: Props = $props(); 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)); @@ -109,9 +114,18 @@ {:then context}
- {context.changeName} + {context.changeName} + + {#if showHistoryButton} + + {#if openHistoryId} + !!openHistoryId, (open) => (open ? undefined : openHistoryId = undefined)} id={openHistoryId} selectedCommitId={activity.commitId}/> + {/if} + {/if}
- + {$t`Preview`} diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index c32382f55f..cb03904fc0 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -181,6 +181,6 @@
{#if selectedRow} - + {/if}
diff --git a/frontend/viewer/src/lib/history/HistoryView.svelte b/frontend/viewer/src/lib/history/HistoryView.svelte index 2094bb516b..fc2c310b80 100644 --- a/frontend/viewer/src/lib/history/HistoryView.svelte +++ b/frontend/viewer/src/lib/history/HistoryView.svelte @@ -8,8 +8,17 @@ import {FormatRelativeDate} from '$lib/components/ui/format'; import ActivityItem from '$lib/activity/ActivityItem.svelte'; - export let id: string; - export let open: boolean; + interface Props { + id: string; + open: boolean; + selectedCommitId?: string | undefined; + } + + let { + id, + open = $bindable(), + selectedCommitId = $bindable(undefined) + }: Props = $props(); useBackHandler({ addToStack: () => open, @@ -17,22 +26,18 @@ key: 'history-view' }); - let loading = false; - let record: HistoryItem | undefined; + let loading = $state(false); const historyService = useHistoryService(); - let history: HistoryItem[]; + let history: HistoryItem[] = $state([]); - $: if (open && id) { - void load(); - } - $: if (!open) reset(); async function load() { loading = true; try { history = []; history = await historyService.load(id); - record = history[0]; + if (!selectedCommitId) + selectedCommitId = history[0]?.commitId; } finally { loading = false; } @@ -43,29 +48,39 @@ const snapshot = await historyService.fetchSnapshot(row, id); Object.assign(row, snapshot); } - record = row; + selectedCommitId = row.commitId; } function reset() { - record = undefined; + selectedCommitId = undefined; history = []; } + + let record = $derived(selectedCommitId ? history.find(h => h.commitId == selectedCommitId) : undefined); + $effect(() => { + if (open && id) { + void load(); + } + if (!open) reset(); + }); - + {$t`History`} {#if !loading} -
+
{#if !history || history.length === 0}
{$t`No history found`}
{:else} `${row.commitId}_${row.changeIndex}`} - class="h-full p-0.5 md:pr-3 after:h-12 after:block !contain-content"> + getKey={row => `${row.commitId}_${row.changeIndex}`} + class="h-full p-0.5 md:pr-3 after:h-12 after:block !contain-content"> {#snippet children(row)} showEntry(row)} @@ -75,20 +90,20 @@
+ actualDateOptions={{ dateStyle: 'medium', timeStyle: 'short' }}/> {row.metadata.authorName}
- {/snippet} + {/snippet}
{/if}
{#if record} - + {/if}
From 62164870c66eb0da1e034c700920a5b43bae470c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 18 Jun 2026 13:12:59 +0700 Subject: [PATCH 09/10] Update locale files to include "History" translation for ActivityItem --- frontend/viewer/src/locales/en.po | 1 + frontend/viewer/src/locales/es.po | 1 + frontend/viewer/src/locales/fr.po | 1 + frontend/viewer/src/locales/id.po | 1 + frontend/viewer/src/locales/ko.po | 1 + frontend/viewer/src/locales/ms.po | 1 + frontend/viewer/src/locales/sw.po | 1 + frontend/viewer/src/locales/vi.po | 1 + 8 files changed, 8 insertions(+) diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 5f9387418a..1a573b5882 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1005,6 +1005,7 @@ msgstr "Headword" msgid "Hide" msgstr "Hide" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 574e759cd0..3e73cf4e16 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -1010,6 +1010,7 @@ msgstr "Palabra clave" msgid "Hide" msgstr "Ocultar" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 6a02d297a2..3acf2315d0 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -1010,6 +1010,7 @@ msgstr "Entrée de dictionnaire" msgid "Hide" msgstr "Cacher" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 91a521112b..36ebae4f36 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -1010,6 +1010,7 @@ msgstr "Headword" msgid "Hide" msgstr "Sembunyikan" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index fa8afc57ea..67a908f4e0 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -1010,6 +1010,7 @@ msgstr "헤드워드" msgid "Hide" msgstr "숨기기" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index e3e637a67a..c876d159d5 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -1010,6 +1010,7 @@ msgstr "Kata Utama" msgid "Hide" msgstr "Sembunyi" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 47e58e173b..1afd24bccb 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -1010,6 +1010,7 @@ msgstr "Neno la kichwa" msgid "Hide" msgstr "Ficha" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index f2fb8ac91f..272201682a 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -1010,6 +1010,7 @@ msgstr "Từ đầu mục" msgid "Hide" msgstr "Ẩn" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" From 6f1e5181f7ade64bcdb4634b832fe4588c7e4f07 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 22 Jun 2026 14:42:25 +0700 Subject: [PATCH 10/10] hoiste the history dialog outside of the each loop --- frontend/viewer/src/lib/activity/ActivityItem.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/viewer/src/lib/activity/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 2edae109bd..3e51182a7b 100644 --- a/frontend/viewer/src/lib/activity/ActivityItem.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItem.svelte @@ -95,6 +95,11 @@ {/if}
+ + {#if openHistoryId} + + !!openHistoryId, (open) => (open ? undefined : openHistoryId = undefined)} id={openHistoryId} selectedCommitId={activity.commitId}/> + {/if} {#if changes}
@@ -120,9 +125,6 @@ - {#if openHistoryId} - !!openHistoryId, (open) => (open ? undefined : openHistoryId = undefined)} id={openHistoryId} selectedCommitId={activity.commitId}/> - {/if} {/if}