Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 107 additions & 31 deletions backend/FwLite/LcmCrdt.Tests/OpenProjectTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<CrdtProjectsService>();

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<IDbContextFactory<LcmCrdtDbContext>>().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<Commit>().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<HistoryService>();

// 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<HistoryService>();
var interceptor = services.GetRequiredService<CommitMetadataInterceptor>();

// 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<IDbContextFactory<LcmCrdtDbContext>>().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<Commit>().AsNoTracking().Select(c => c.ClientId).Distinct().ToArrayAsync();
commitClientIds.Should().ContainSingle().Which.Should().Be(miniLcmApi.ProjectData.ClientId);
await dbContext.Database.EnsureDeletedAsync();
private static async Task<string?> 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<IServiceProvider, IMiniLcmApi, Task> 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<CrdtProjectsService>().CreateProjectFromTemplate(request, vernacularWs);
try
{
await test(services, await services.OpenCrdtProject(project));
}
finally
{
await using var dbContext = await services.GetRequiredService<IDbContextFactory<LcmCrdtDbContext>>().CreateDbContextAsync();
await dbContext.Database.EnsureDeletedAsync();
}
}

[Theory]
Expand Down
29 changes: 29 additions & 0 deletions backend/FwLite/LcmCrdt/CommitMetadataInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using SIL.Harmony.Core;

namespace LcmCrdt;

/// <summary>
/// Async-flow-scoped hook for shaping the <see cref="CommitMetadata"/> of commits written by
/// <see cref="CrdtMiniLcmApi"/> for the duration of an <see cref="Intercept"/> scope — e.g. attributing
/// template-imported system data to the System author. The override lives in an <see cref="AsyncLocal{T}"/>,
/// 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. <see cref="CrdtMiniLcmApi"/> applies it when building metadata.
/// </summary>
public class CommitMetadataInterceptor
{
private readonly AsyncLocal<Action<CommitMetadata>?> _interceptor = new();

public IDisposable Intercept(Action<CommitMetadata> 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();
}
}
2 changes: 2 additions & 0 deletions backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class CrdtMiniLcmApi(
IOptions<LcmCrdtConfig> config,
ILogger<CrdtMiniLcmApi> logger,
LcmMediaService lcmMediaService,
CommitMetadataInterceptor commitMetadataInterceptor,
EntrySearchService? entrySearchService = null) : IMiniLcmApi
{
private Guid ClientId { get; } = projectService.ProjectData.ClientId;
Expand All @@ -45,6 +46,7 @@ private CommitMetadata NewMetadata()
AuthorName = ProjectData.LastUserName ?? config.Value.DefaultAuthorForCommits,
AuthorId = ProjectData.LastUserId
};
commitMetadataInterceptor.Apply(metadata);
return metadata;
}
private async Task<Commit> AddChange(IChange change)
Expand Down
11 changes: 8 additions & 3 deletions backend/FwLite/LcmCrdt/CrdtProjectsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -149,9 +148,11 @@ public virtual async Task<CrdtProject> 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
Expand All @@ -161,7 +162,11 @@ public virtual async Task<CrdtProject> CreateProjectFromTemplate(
var api = provider.GetRequiredService<IMiniLcmApi>();
var jsonOptions = provider.GetRequiredService<IOptions<CrdtConfig>>().Value.JsonSerializerOptions;
var snapshot = ProjectTemplate.CreateNewSnapshot(jsonOptions, vernacularWs, analysisWs);
await projectImporter.ImportProject(api, snapshot);
var commitMetadataInterceptor = provider.GetRequiredService<CommitMetadataInterceptor>();
using (commitMetadataInterceptor.Intercept(CommitHelpers.StampAsTemplate))
{
await projectImporter.ImportProject(api, snapshot);
}
if (callerAfterCreate is not null) await callerAfterCreate(provider, project);
}
});
Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se
crdtConfig.LocalResourceCachePath = Path.Combine(lcmConfig.Value.ProjectPath, "localResourcesCache");
});
services.AddScoped<IMiniLcmApi, CrdtMiniLcmApi>();
services.AddScoped<CommitMetadataInterceptor>();
services.AddScoped<MiniLcmRepositoryFactory>();
services.AddMiniLcmValidators();
services.AddSingleton<ProjectDataCache>();
Expand Down
11 changes: 11 additions & 0 deletions backend/FwLite/LcmCrdt/Utils/CommitHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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";
}
}
1 change: 0 additions & 1 deletion frontend/viewer/src/home/CreateProjectDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
error = undefined;
submitted = false;
name = '';
code = '';
vernacularWs = '';
analysisWs = '';
loading = false;
Expand Down
2 changes: 1 addition & 1 deletion frontend/viewer/src/home/HomeView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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()}>
<span>{$t`New Project`} ({$t`Local only`})</span>
<span>{$t`New Project (Local only)`}</span>
<span class="text-sm text-muted-foreground">{$t`Create a new FieldWorks Lite project`}</span>
</ListItem>
</DevContent>
Expand Down
29 changes: 8 additions & 21 deletions frontend/viewer/src/lib/activity/ActivityFilter.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script lang="ts">
import flexLogo from '$lib/assets/flex-logo.png';
import {useHistoryService} from '$lib/services/history-service';
import {t} from 'svelte-i18n-lingui';
import {resource} from 'runed';
Expand All @@ -15,17 +14,17 @@
import {
ALL_AUTHORS,
ALL_CHANGE_TYPES,
FIELDWORKS_AUTHOR_KEY,
UNKNOWN_AUTHOR_KEY,
applyMultiSelectValue,
authorFilterKey,
compareActivityAuthors,
createDefaultActivityFilters,
isAllFilterSelection,
resolveFilterKeys,
wellKnownAuthorKeyToLabel,
type ActivityFilters,
type MultiFilterSelection,
} from './utils';
import AuthorLabel from './AuthorLabel.svelte';

type Props = {
filters?: ActivityFilters;
Expand Down Expand Up @@ -79,7 +78,8 @@
};

function authorKeyToLabel(key: string): string {
if (key === UNKNOWN_AUTHOR_KEY) return $t`Unknown`;
const wellKnownLabel = wellKnownAuthorKeyToLabel(key);
if (wellKnownLabel) return wellKnownLabel;
const author = authors.current.find(a => authorFilterKey(a) === key);
return author?.authorName ?? key;
}
Expand All @@ -99,20 +99,6 @@
}
</script>

{#snippet authorLabel(key: string)}
{@const label = authorKeyToLabel(key)}
<span class="inline-flex items-center gap-1">
{#if key === UNKNOWN_AUTHOR_KEY}
<span class="italic">{$t`Unknown`}</span>
{:else}
{label}
{/if}
{#if key === FIELDWORKS_AUTHOR_KEY}
<Icon class="size-5" src={flexLogo} alt={$t`FieldWorks logo`} />
{/if}
</span>
{/snippet}

<div class="flex flex-col gap-2 mb-1">
<div class="flex flex-wrap gap-2">
<SidebarTrigger icon="i-mdi-menu" class="aspect-square p-0 shrink-0" />
Expand All @@ -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])}
<AuthorLabel authorId={selectedAuthor?.authorId} authorName={selectedAuthor?.authorName} />
{:else}
{$t`${filters.authorFilterKeys.length} authors`}
{/if}
Expand All @@ -141,8 +128,8 @@
</Select.Item>
{#each authors.current as author (authorFilterKey(author))}
{@const key = authorFilterKey(author)}
<Select.Item value={key} label={author.authorName ?? $t`Unknown`}>
{@render authorLabel(key)}
<Select.Item value={key} label={authorKeyToLabel(key)}>
<AuthorLabel authorId={author.authorId} authorName={author.authorName} />
<span class="text-muted-foreground ml-1">({author.commitCount})</span>
</Select.Item>
{/each}
Expand Down
7 changes: 2 additions & 5 deletions frontend/viewer/src/lib/activity/ActivityItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,11 +60,7 @@
<span>
<span>
{$t`Author:`}
{#if activity.metadata.authorName}
<span class="font-semibold">{activity.metadata.authorName}</span>
{:else}
<span class="opacity-75 italic">{$t`Unknown`}</span>
{/if}
<AuthorLabel class="font-semibold" authorId={activity.metadata.authorId} authorName={activity.metadata.authorName} />
</span>
{#if activity.changes.length > 1}
<span>{$t`– (${activity.changes.length} changes)`}</span>
Expand Down
Loading
Loading