diff --git a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs index a650a5f01a..7ce2d17008 100644 --- a/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs @@ -1,7 +1,7 @@ +using LcmCrdt.Utils; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using SIL.Harmony; using static LcmCrdt.CrdtProjectsService; namespace LcmCrdt.Tests; @@ -76,46 +76,122 @@ public async Task ProjectDbIsDeletedIfCreateFails() public async Task CreateProjectFromTemplateAppliesRequestedIdentity() { var code = $"blank-from-template-{Guid.NewGuid():N}"; - var sqliteFile = $"{code}.sqlite"; - if (File.Exists(sqliteFile)) File.Delete(sqliteFile); - var builder = Host.CreateEmptyApplicationBuilder(null); - builder.Services.AddTestLcmCrdtClient(); - using var host = builder.Build(); - var asyncScope = host.Services.CreateAsyncScope(); - var crdtProjectsService = asyncScope.ServiceProvider.GetRequiredService(); - - var crdtProject = await crdtProjectsService.CreateProjectFromTemplate(new( + var request = new CreateProjectRequest( Name: "Blank From Template", Code: code, Path: "", - Role: UserProjectRole.Manager), - vernacularWs: "fr"); + Role: UserProjectRole.Manager); - var miniLcmApi = (CrdtMiniLcmApi)await asyncScope.ServiceProvider.OpenCrdtProject(crdtProject); - miniLcmApi.ProjectData.Name.Should().Be("Blank From Template"); - miniLcmApi.ProjectData.Code.Should().Be(code); - miniLcmApi.ProjectData.Role.Should().Be(UserProjectRole.Manager); - miniLcmApi.ProjectData.ClientId.Should().NotBe(Guid.Empty); + await WithProjectFromTemplate(request, "fr", async (services, api) => + { + var miniLcmApi = (CrdtMiniLcmApi)api; + miniLcmApi.ProjectData.Name.Should().Be("Blank From Template"); + miniLcmApi.ProjectData.Code.Should().Be(code); + miniLcmApi.ProjectData.Role.Should().Be(UserProjectRole.Manager); + miniLcmApi.ProjectData.ClientId.Should().NotBe(Guid.Empty); + + var morphTypes = await miniLcmApi.GetMorphTypes().ToArrayAsync(); + morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); + + var writingSystems = await miniLcmApi.GetWritingSystems(); + writingSystems.Analysis.Should().ContainSingle().Which.WsId.Should().Be((WritingSystemId)"en"); + writingSystems.Vernacular.Should().ContainSingle().Which.WsId.Should().Be((WritingSystemId)"fr"); + + var entry = await miniLcmApi.CreateEntry(new Entry + { + LexemeForm = { { "fr", "post-template" } }, + }); + (await miniLcmApi.GetEntry(entry.Id)).Should().NotBeNull(); + + await using var dbContext = await services.GetRequiredService>().CreateDbContextAsync(); + // The template is imported through the normal MiniLcm write path, so every commit — the imported + // system data and the post-template writes — is authored under this project's own ClientId. + var commitClientIds = await dbContext.Set().AsNoTracking().Select(c => c.ClientId).Distinct().ToArrayAsync(); + commitClientIds.Should().ContainSingle().Which.Should().Be(miniLcmApi.ProjectData.ClientId); + }); + } - var morphTypes = await miniLcmApi.GetMorphTypes().ToArrayAsync(); - morphTypes.Should().HaveCount(CanonicalMorphTypes.All.Count); + [Fact] + public async Task TemplateCommitsUseTheSystemAuthorAndAreStampedAsTemplateCommits() + { + // Create as a signed-in user so the template stamp has a real author to override. + var request = new CreateProjectRequest("Template Author", $"template-author-{Guid.NewGuid():N}", + AuthenticatedUser: "Test User", AuthenticatedUserId: "test-user-id", Role: UserProjectRole.Manager); + + await WithProjectFromTemplate(request, "fr", async (services, api) => + { + var historyService = services.GetRequiredService(); + + // The imported template data is re-attributed to System and tagged, overriding the signed-in user. + var templateCommits = await historyService.ProjectActivity(0, 1000, new ActivityQuery()).ToArrayAsync(); + templateCommits.Should().NotBeEmpty(); + templateCommits.Should().AllSatisfy(activity => + { + activity.Metadata.AuthorId.Should().Be(CommitHelpers.SystemAuthorId); + activity.Metadata.AuthorName.Should().BeNull("the UI owns and translates the System display name"); + activity.Metadata[CommitHelpers.TemplateProp].Should().Be("true"); + }); + + // A later edit keeps the signed-in user (not System) — the stamp is scoped to the template import. + await api.CreateEntry(new() { LexemeForm = { ["fr"] = "post-template" } }); + var latest = (await historyService.ProjectActivity(0, 1, new ActivityQuery()).ToArrayAsync()).Single(); + latest.Metadata.AuthorId.Should().Be("test-user-id"); + latest.Metadata.AuthorName.Should().Be("Test User"); + latest.Metadata[CommitHelpers.TemplateProp].Should().BeNull(); + }); + } - var writingSystems = await miniLcmApi.GetWritingSystems(); - writingSystems.Analysis.Should().ContainSingle().Which.WsId.Should().Be((WritingSystemId)"en"); - writingSystems.Vernacular.Should().ContainSingle().Which.WsId.Should().Be((WritingSystemId)"fr"); + [Fact] + public async Task CommitMetadataOverrideIsScopedAndRestored() + { + var request = new CreateProjectRequest("Commit Metadata Scope", $"commit-metadata-scope-{Guid.NewGuid():N}", + Role: UserProjectRole.Manager); - var entry = await miniLcmApi.CreateEntry(new Entry + await WithProjectFromTemplate(request, "fr", async (services, api) => { - LexemeForm = { { "fr", "post-template" } }, + var historyService = services.GetRequiredService(); + var interceptor = services.GetRequiredService(); + + // A commit made inside the interceptor scope carries the overridden author. + using (interceptor.Intercept(metadata => metadata.AuthorId = "override-id")) + { + await api.CreateEntry(new() { LexemeForm = { ["fr"] = "in scope" } }); + } + (await LatestCommitAuthorId(historyService)).Should().Be("override-id"); + + // Once the scope ends the override is gone. + await api.CreateEntry(new() { LexemeForm = { ["fr"] = "after scope" } }); + (await LatestCommitAuthorId(historyService)).Should().BeNull(); }); - (await miniLcmApi.GetEntry(entry.Id)).Should().NotBeNull(); + } - await using var dbContext = await asyncScope.ServiceProvider.GetRequiredService>().CreateDbContextAsync(); - // The template is imported through the normal MiniLcm write path, so every commit — the imported - // system data and the post-template writes — is authored under this project's own ClientId. - var commitClientIds = await dbContext.Set().AsNoTracking().Select(c => c.ClientId).Distinct().ToArrayAsync(); - commitClientIds.Should().ContainSingle().Which.Should().Be(miniLcmApi.ProjectData.ClientId); - await dbContext.Database.EnsureDeletedAsync(); + private static async Task LatestCommitAuthorId(HistoryService historyService) + { + var latest = await historyService.ProjectActivity(skip: 0, take: 1).ToArrayAsync(); + return latest.Single().Metadata.AuthorId; + } + + // Spins up a fresh CRDT host, creates a project from the template, opens it, runs the test, and deletes the db. + private static async Task WithProjectFromTemplate( + CreateProjectRequest request, + WritingSystemId vernacularWs, + Func test) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Services.AddTestLcmCrdtClient(); + using var host = builder.Build(); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var project = await services.GetRequiredService().CreateProjectFromTemplate(request, vernacularWs); + try + { + await test(services, await services.OpenCrdtProject(project)); + } + finally + { + await using var dbContext = await services.GetRequiredService>().CreateDbContextAsync(); + await dbContext.Database.EnsureDeletedAsync(); + } } [Theory] diff --git a/backend/FwLite/LcmCrdt/CommitMetadataInterceptor.cs b/backend/FwLite/LcmCrdt/CommitMetadataInterceptor.cs new file mode 100644 index 0000000000..3182eb7da1 --- /dev/null +++ b/backend/FwLite/LcmCrdt/CommitMetadataInterceptor.cs @@ -0,0 +1,29 @@ +using SIL.Harmony.Core; + +namespace LcmCrdt; + +/// +/// Async-flow-scoped hook for shaping the of commits written by +/// for the duration of an scope — e.g. attributing +/// template-imported system data to the System author. The override lives in an , +/// so it applies only to commits made within the awaited call tree of the scope and can't leak into other +/// operations that share the same DI scope. applies it when building metadata. +/// +public class CommitMetadataInterceptor +{ + private readonly AsyncLocal?> _interceptor = new(); + + public IDisposable Intercept(Action interceptor) + { + var previous = _interceptor.Value; + _interceptor.Value = interceptor; + return new DisposableAction(() => _interceptor.Value = previous); + } + + public void Apply(CommitMetadata metadata) => _interceptor.Value?.Invoke(metadata); + + private sealed class DisposableAction(Action onDispose) : IDisposable + { + public void Dispose() => onDispose(); + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index e1bfb02511..1aa48ada4a 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -30,6 +30,7 @@ public class CrdtMiniLcmApi( IOptions config, ILogger logger, LcmMediaService lcmMediaService, + CommitMetadataInterceptor commitMetadataInterceptor, EntrySearchService? entrySearchService = null) : IMiniLcmApi { private Guid ClientId { get; } = projectService.ProjectData.ClientId; @@ -45,6 +46,7 @@ private CommitMetadata NewMetadata() AuthorName = ProjectData.LastUserName ?? config.Value.DefaultAuthorForCommits, AuthorId = ProjectData.LastUserId }; + commitMetadataInterceptor.Apply(metadata); return metadata; } private async Task AddChange(IChange change) diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index c4811e202e..c06a2e03f1 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -2,14 +2,13 @@ using System.Text; using System.Text.RegularExpressions; using LcmCrdt.MediaServer; +using LcmCrdt.Utils; using SIL.Harmony; -using MiniLcm; using MiniLcm.Import; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using LcmCrdt.Objects; using LcmCrdt.Project; using Microsoft.EntityFrameworkCore; using MiniLcm.Project; @@ -149,9 +148,11 @@ public virtual async Task CreateProjectFromTemplate( // Templated projects are created locally; this path has no way to associate one with a server // at creation time, so reject a Domain up front — the invariant holds where the project is born. if (request.Domain is not null) + { throw new ArgumentException( "Templated projects can't be associated with a server at creation time — they're local-only.", nameof(request)); + } var callerAfterCreate = request.AfterCreate; return await CreateProject(request with @@ -161,7 +162,11 @@ public virtual async Task CreateProjectFromTemplate( var api = provider.GetRequiredService(); var jsonOptions = provider.GetRequiredService>().Value.JsonSerializerOptions; var snapshot = ProjectTemplate.CreateNewSnapshot(jsonOptions, vernacularWs, analysisWs); - await projectImporter.ImportProject(api, snapshot); + var commitMetadataInterceptor = provider.GetRequiredService(); + using (commitMetadataInterceptor.Intercept(CommitHelpers.StampAsTemplate)) + { + await projectImporter.ImportProject(api, snapshot); + } if (callerAfterCreate is not null) await callerAfterCreate(provider, project); } }); diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 4aaa97f3b7..d6282371ed 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -69,6 +69,7 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se crdtConfig.LocalResourceCachePath = Path.Combine(lcmConfig.Value.ProjectPath, "localResourcesCache"); }); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddMiniLcmValidators(); services.AddSingleton(); diff --git a/backend/FwLite/LcmCrdt/Utils/CommitHelpers.cs b/backend/FwLite/LcmCrdt/Utils/CommitHelpers.cs index 119eb9d7b2..9294c1f9be 100644 --- a/backend/FwLite/LcmCrdt/Utils/CommitHelpers.cs +++ b/backend/FwLite/LcmCrdt/Utils/CommitHelpers.cs @@ -5,6 +5,10 @@ namespace LcmCrdt.Utils; public static class CommitHelpers { public const string SyncDateProp = "SyncDate"; + // Mirrored by SYSTEM_AUTHOR_KEY in frontend/viewer/src/lib/activity/utils.ts + public const string SystemAuthorId = "00000000-0000-0000-0000-000000000001"; + public const string TemplateProp = "Template"; + public static DateTimeOffset? SyncDate(this CommitBase commit) { var dateAsString = commit.Metadata[SyncDateProp]; @@ -21,4 +25,11 @@ public static void SetSyncDate(this CommitBase commit, DateTimeOffset? syncDate) { commit.Metadata[SyncDateProp] = syncDate?.ToString("u"); } + + public static void StampAsTemplate(CommitMetadata metadata) + { + metadata.AuthorId = SystemAuthorId; + metadata.AuthorName = null; + metadata[TemplateProp] = "true"; + } } diff --git a/frontend/viewer/src/home/CreateProjectDialog.svelte b/frontend/viewer/src/home/CreateProjectDialog.svelte index 99fa5540a1..448bf93f85 100644 --- a/frontend/viewer/src/home/CreateProjectDialog.svelte +++ b/frontend/viewer/src/home/CreateProjectDialog.svelte @@ -37,7 +37,6 @@ error = undefined; submitted = false; name = ''; - code = ''; vernacularWs = ''; analysisWs = ''; loading = false; diff --git a/frontend/viewer/src/home/HomeView.svelte b/frontend/viewer/src/home/HomeView.svelte index 7cc6dd7da0..06e9c53117 100644 --- a/frontend/viewer/src/home/HomeView.svelte +++ b/frontend/viewer/src/home/HomeView.svelte @@ -219,7 +219,7 @@ icon="i-mdi-book-plus-outline" class="mb-2 bg-transparent shadow-none hover:shadow-none border-2 border-dashed border-muted-foreground/40" onclick={() => createProjectDialog?.openDialog()}> - {$t`New Project`} ({$t`Local only`}) + {$t`New Project (Local only)`} {$t`Create a new FieldWorks Lite project`} diff --git a/frontend/viewer/src/lib/activity/ActivityFilter.svelte b/frontend/viewer/src/lib/activity/ActivityFilter.svelte index a9a84f3e68..5c2698c5f6 100644 --- a/frontend/viewer/src/lib/activity/ActivityFilter.svelte +++ b/frontend/viewer/src/lib/activity/ActivityFilter.svelte @@ -1,5 +1,4 @@ -{#snippet authorLabel(key: string)} - {@const label = authorKeyToLabel(key)} - - {#if key === UNKNOWN_AUTHOR_KEY} - {$t`Unknown`} - {:else} - {label} - {/if} - {#if key === FIELDWORKS_AUTHOR_KEY} - - {/if} - -{/snippet} -
@@ -124,7 +110,8 @@ {:else if filters.authorFilterKeys.length === 0} {$t`No authors`} {:else if filters.authorFilterKeys.length === 1} - {@render authorLabel(filters.authorFilterKeys[0])} + {@const selectedAuthor = authors.current.find(a => authorFilterKey(a) === filters.authorFilterKeys[0])} + {:else} {$t`${filters.authorFilterKeys.length} authors`} {/if} @@ -141,8 +128,8 @@ {#each authors.current as author (authorFilterKey(author))} {@const key = authorFilterKey(author)} - - {@render authorLabel(key)} + + ({author.commitCount}) {/each} diff --git a/frontend/viewer/src/lib/activity/ActivityItem.svelte b/frontend/viewer/src/lib/activity/ActivityItem.svelte index 2bc998e217..3dc4673c04 100644 --- a/frontend/viewer/src/lib/activity/ActivityItem.svelte +++ b/frontend/viewer/src/lib/activity/ActivityItem.svelte @@ -26,6 +26,7 @@ import {VList} from 'virtua/svelte'; import ActivityItemChangePreview from './ActivityItemChangePreview.svelte'; import {formatJsonForUi} from './utils'; + import AuthorLabel from './AuthorLabel.svelte'; import type {HTMLAttributes} from 'svelte/elements'; import {cn} from '$lib/utils'; import * as Popover from '$lib/components/ui/popover'; @@ -59,11 +60,7 @@ {$t`Author:`} - {#if activity.metadata.authorName} - {activity.metadata.authorName} - {:else} - {$t`Unknown`} - {/if} + {#if activity.changes.length > 1} {$t`– (${activity.changes.length} changes)`} diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index 39cf17cfdb..9fb0a255e8 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -21,6 +21,7 @@ type ActivityFilters, type ActivityLoad, } from './utils'; + import AuthorLabel from './AuthorLabel.svelte'; const historyService = useHistoryService(); @@ -163,9 +164,7 @@ - - {row.metadata.authorName ?? $t`Unknown`} - +
{/snippet} diff --git a/frontend/viewer/src/lib/activity/AuthorLabel.svelte b/frontend/viewer/src/lib/activity/AuthorLabel.svelte new file mode 100644 index 0000000000..9e6eb43638 --- /dev/null +++ b/frontend/viewer/src/lib/activity/AuthorLabel.svelte @@ -0,0 +1,30 @@ + + + + {#if key === UNKNOWN_AUTHOR_KEY} + {$t`Unknown`} + {:else} + {label} + {/if} + {#if key === FIELDWORKS_AUTHOR_KEY} + + {:else if key === SYSTEM_AUTHOR_KEY} + + {/if} + diff --git a/frontend/viewer/src/lib/activity/utils.ts b/frontend/viewer/src/lib/activity/utils.ts index 6740dc5713..d27f8fc5fc 100644 --- a/frontend/viewer/src/lib/activity/utils.ts +++ b/frontend/viewer/src/lib/activity/utils.ts @@ -1,8 +1,11 @@ import {ActivitySort, type IActivityAuthor, type IActivityQuery, type IProjectActivity} from '$lib/dotnet-types'; +import {gt} from 'svelte-i18n-lingui'; export const ALL_AUTHORS = '__all__'; export const UNKNOWN_AUTHOR_KEY = '__unknown__'; export const FIELDWORKS_AUTHOR_KEY = authorFilterKey({authorName: 'FieldWorks'}); +// Mirrors SystemAuthorId in backend/FwLite/LcmCrdt/Utils/CommitHelpers.cs +export const SYSTEM_AUTHOR_KEY = '00000000-0000-0000-0000-000000000001'; export const ALL_CHANGE_TYPES = '__all__'; export const MIN_VISIBLE_FILTERED = 20; @@ -68,17 +71,24 @@ export function authorFilterKey(author: Omit): s return `name:${author.authorName}`; } +export function wellKnownAuthorKeyToLabel(key: string | undefined): string | undefined { + if (key === UNKNOWN_AUTHOR_KEY) return gt`Unknown`; + if (key === SYSTEM_AUTHOR_KEY) return gt`System`; + return undefined; +} + function authorSortRank(author: IActivityAuthor): number { - const key = authorFilterKey(author); - if (key === UNKNOWN_AUTHOR_KEY) return 0; // 1st - if (key === FIELDWORKS_AUTHOR_KEY) return 1; // 2nd - return 2; + // Unknown is pinned to the top (it's the catch-all, not a real author); FieldWorks, System + // and people all sort alphabetically together. + return authorFilterKey(author) === UNKNOWN_AUTHOR_KEY ? 0 : 1; } export function compareActivityAuthors(a: IActivityAuthor, b: IActivityAuthor): number { const rankDiff = authorSortRank(a) - authorSortRank(b); if (rankDiff !== 0) return rankDiff; - return (a.authorName || '').localeCompare(b.authorName || ''); + const aName = wellKnownAuthorKeyToLabel(a.authorId) || a.authorName || a.authorId || ''; + const bName = wellKnownAuthorKeyToLabel(b.authorId) || b.authorName || b.authorId || ''; + return aName.localeCompare(bName); } export function applyMultiSelectValue( diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index 3e1c92a748..6b84bbad2d 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -1174,7 +1174,6 @@ msgstr "Local" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Local only" @@ -1328,10 +1327,13 @@ msgid "New Entry" msgstr "New Entry" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "New Project" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "New Project (Local only)" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1940,6 +1942,7 @@ msgid "Syncing..." msgstr "Syncing..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "System" @@ -2090,9 +2093,9 @@ 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/lib/activity/utils.ts #: 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 23be159e98..4466e176aa 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -1179,7 +1179,6 @@ msgstr "Local" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Sólo local" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "Nueva entrada" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "Sincronizando..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "Sistema" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: 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 cb7f3351cc..11ac66807a 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -1179,7 +1179,6 @@ msgstr "Local" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Uniquement au niveau local" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "Nouvelle entrée" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "Synchronisation en cours..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "Système" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: 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 22a8ccbb86..cc4db4c822 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -1179,7 +1179,6 @@ msgstr "Lokal" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Hanya lokal" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "Entri Baru" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "Menyinkronkan..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "Sistem" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: 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 5570a00efb..463bc45c15 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -1179,7 +1179,6 @@ msgstr "로컬" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "로컬 전용" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "새 항목" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "동기화..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "시스템" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: 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 882f4fea77..99a2ba01ca 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -1179,7 +1179,6 @@ msgstr "Tempatan" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Tempatan sahaja" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "Entri Baru" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "Sedang disegerakkan..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "Sistem" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: 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 a942c1bd89..2dd287849e 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -1179,7 +1179,6 @@ msgstr "Mitaa" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Mitaa tu" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "Ingizo Mpya" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "Inaoanisha..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "Mfumo" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: 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 0b88e18e74..8a88c9fac6 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -1179,7 +1179,6 @@ msgstr "Cục bộ" #. Subtitle shown on a project card when the project has no server configured (no sync partner). #: src/home/HomeView.svelte -#: src/home/HomeView.svelte msgid "Local only" msgstr "Chỉ cục bộ" @@ -1333,10 +1332,13 @@ msgid "New Entry" msgstr "Mục mới" #: src/home/CreateProjectDialog.svelte -#: src/home/HomeView.svelte msgid "New Project" msgstr "" +#: src/home/HomeView.svelte +msgid "New Project (Local only)" +msgstr "" + #. Relevant view: Lite #. Classic view equivalent: "New Entry" #. Dialog title for creating new word @@ -1945,6 +1947,7 @@ msgid "Syncing..." msgstr "Đang đồng bộ..." #. Theme option +#: src/lib/activity/utils.ts #: src/lib/components/ThemePicker.svelte msgid "System" msgstr "Hệ thống" @@ -2095,9 +2098,9 @@ 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/lib/activity/utils.ts #: src/project/sync/FwLiteToFwMergeDetails.svelte #: src/project/sync/FwLiteToFwMergeDetails.svelte msgid "Unknown"