diff --git a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs index 3b86591a09..5921709ec6 100644 --- a/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/HistoryServiceJsInvokable.cs @@ -14,9 +14,27 @@ 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[]? authorFilterKeys = null, + string[]? changeTypeKeys = null, + ActivitySort sort = ActivitySort.NewestFirst) { - return await historyService.ProjectActivity(skip, take).ToArrayAsync(); + return await historyService.ProjectActivity(skip, take, + new ActivityQuery(authorFilterKeys, changeTypeKeys, 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..09ecf842dd 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs @@ -18,7 +18,16 @@ 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[]? authorFilterKeys = null, + string[]? changeTypeKeys = null, + ActivitySort sort = ActivitySort.NewestFirst) => + 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 new file mode 100644 index 0000000000..376395fec8 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs @@ -0,0 +1,166 @@ +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(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); + + activities.Should().OnlyContain(a => a.Metadata.AuthorId == "alice-id"); + activities.Should().HaveCountGreaterThanOrEqualTo(1); + } + + [Fact] + public async Task ProjectActivity_AuthorFilterKeys_ExcludesUnselectedAuthors() + { + await AddEntryCommit(new CommitMetadata { AuthorName = "Alice", AuthorId = "alice-id" }); + await AddEntryCommit(new CommitMetadata { AuthorName = "FieldWorks" }); + + var activities = await Service.ProjectActivity(0, 100, new ActivityQuery(AuthorFilterKeys: ["alice-id"])).ToArrayAsync(); + + 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(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() + { + 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..9d039a65c8 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -5,12 +5,37 @@ 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[]? 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, @@ -18,6 +43,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 +93,169 @@ 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, changeEntities, 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, + IQueryable> changeEntities, + ActivityQuery query) + { + if (query.AuthorFilterKeys is { Length: 0 }) + { + return commits.ToLinqToDB().Where(_ => false); + } + + 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 => + (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) ?? "")); + } + + if (query.ChangeTypeKeys is { Length: 0 }) + { + return commits.ToLinqToDB().Where(_ => false); + } + + if (query.ChangeTypeKeys is { Length: > 0 }) + { + var changeTypeKeys = query.ChangeTypeKeys; + commits = commits.ToLinqToDB().Where(c => + changeEntities.ToLinqToDB().Any(ce => + ce.CommitId == c.Id + && changeTypeKeys.Contains(Sql.Expr( + "json_extract({0}, '$.\"$type\"')", ce.Change)))); + } + + 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..7edef107db --- /dev/null +++ b/frontend/viewer/src/lib/activity/ActivityFilter.svelte @@ -0,0 +1,183 @@ + + +
+
+ + + + {#if isAllFilterSelection(filters.authorFilterKeys, authorKeys)} + {$t`All authors`} + {:else if filters.authorFilterKeys.length === 0} + {$t`No authors`} + {:else if filters.authorFilterKeys.length === 1} + {authorLabel(filters.authorFilterKeys[0])} + {:else} + {$t`${filters.authorFilterKeys.length} authors`} + {/if} + + + + {#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)} + + {author.authorName ?? $t`Unknown`} + ({author.commitCount}) + + {/each} + + + + + + {#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} + {$t`${filters.changeTypeFilterKeys.length} change types`} + {/if} + + + + {#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} + ({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/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 852e943092..3e51182a7b 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)); @@ -90,6 +95,11 @@ {/if} + + {#if openHistoryId} + + !!openHistoryId, (open) => (open ? undefined : openHistoryId = undefined)} id={openHistoryId} selectedCommitId={activity.commitId}/> + {/if} {#if changes}
@@ -109,9 +119,15 @@ {:then context}
- {context.changeName} + {context.changeName} + + {#if showHistoryButton} + + {/if}
- + {$t`Preview`} diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index 0604703422..cb03904fc0 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -1,93 +1,186 @@ -{#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} selected={selectedRow?.commitId === row.commitId} class="mb-2"> {row.changeName} -
- +
+ + {#if !row.metadata.extraMetadata['SyncDate']} + + {/if} - {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..7edbe28bf6 100644 --- a/frontend/viewer/src/lib/activity/utils.ts +++ b/frontend/viewer/src/lib/activity/utils.ts @@ -1,7 +1,87 @@ +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 MultiFilterSelection = string[] | 'all'; + +export type ActivityFilters = { + authorFilterKeys: MultiFilterSelection; + changeTypeFilterKeys: MultiFilterSelection; + sort: ActivitySort; +}; + +export function createDefaultActivityFilters(): ActivityFilters { + return { + 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 { + authorFilterKeys: filters.authorFilterKeys === 'all' ? undefined : filters.authorFilterKeys, + changeTypeKeys: filters.changeTypeFilterKeys === 'all' ? undefined : filters.changeTypeFilterKeys, + 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 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 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 2a3268dfe7..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 @@ -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, authorFilterKeys?: string[], changeTypeKeys?: string[], 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..686a24fa44 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/IActivityQuery.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {ActivitySort} from './ActivitySort'; + +export interface IActivityQuery +{ + authorFilterKeys?: string[]; + changeTypeKeys?: string[]; + 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..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 @@ -13,5 +13,6 @@ export interface IProjectActivity changes: IChangeEntity[]; metadata: ICommitMetadata; changeName: string; + changeTypes: string[]; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/LcmCrdt/index.ts index 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..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}
diff --git a/frontend/viewer/src/lib/services/history-service.ts b/frontend/viewer/src/lib/services/history-service.ts index e8f6bf163c..2df3785633 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) as HistoryItem[]; 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,32 @@ 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?.authorFilterKeys, + query?.changeTypeKeys, + 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..1a573b5882 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" @@ -185,6 +193,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 +772,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 +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" @@ -1091,6 +1120,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 +1296,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) @@ -1275,6 +1316,14 @@ msgstr "No activity found" 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." @@ -1389,6 +1438,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 +1860,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}" @@ -1892,6 +1956,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." @@ -1979,7 +2044,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..3e73cf4e16 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" @@ -190,6 +198,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 +777,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 +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" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,14 @@ msgstr "No se ha encontrado actividad" 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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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." @@ -1984,7 +2049,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..3acf2315d0 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" @@ -190,6 +198,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 +777,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 +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" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,14 @@ msgstr "Aucune activité trouvée" 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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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." @@ -1984,7 +2049,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..36ebae4f36 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" @@ -190,6 +198,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 +777,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 +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" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,14 @@ msgstr "Tidak ada aktivitas yang ditemukan" 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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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." @@ -1984,7 +2049,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..67a908f4e0 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" @@ -190,6 +198,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 +777,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 +1010,7 @@ msgstr "헤드워드" msgid "Hide" msgstr "숨기기" +#: src/lib/activity/ActivityItem.svelte #: src/lib/history/HistoryView.svelte #: src/project/browse/EntryMenu.svelte msgid "History" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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 "변경 내용이 아직 업로드되지 않았습니다. 변경 내용을 공유하려면 온라인 상태이고 로그인했는지 확인하세요." @@ -1984,7 +2049,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..c876d159d5 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" @@ -190,6 +198,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 +777,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 +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" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,14 @@ msgstr "Tiada aktiviti ditemui" 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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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." @@ -1984,7 +2049,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..1afd24bccb 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" @@ -190,6 +198,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 +777,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 +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" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,14 @@ msgstr "Hakuna shughuli iliyopatikana" 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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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." @@ -1984,7 +2049,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..272201682a 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" @@ -190,6 +198,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 +777,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 +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" @@ -1096,6 +1125,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 +1301,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) @@ -1280,6 +1321,14 @@ msgstr "Không tìm thấy hoạt động" 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." @@ -1394,6 +1443,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 +1865,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}" @@ -1897,6 +1961,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." @@ -1984,7 +2049,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"