From f77038b9d0a8f5fc85899b7021eb58f0092297d2 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 7 Jul 2026 22:44:49 +0200 Subject: [PATCH 01/16] Add plugin system MVP for FW Lite Plugins are single-HTML-file extensions stored as CRDT entities (manager-only writes, never synced to FLEx), run in an opaque-origin sandboxed iframe with a postMessage RPC bridge. API v1: reads + per-operation user-approved createEntry/updateEntry, per-plugin storage, offline-by-default CSP with a declarative internet permission, per-content-hash run consent. Includes a generated per-project AI prompt for authoring plugins, three example plugins, and Playwright/unit/backend test coverage. Co-Authored-By: Claude Fable 5 --- .../Services/MiniLcmJsInvokable.cs | 40 +- ...lizationRegressionData.latest.verified.txt | 16 + .../LcmCrdt.Tests/Changes/UseChangesTests.cs | 17 + .../LcmCrdt.Tests/ConfigRegistrationTests.cs | 1 + ...lizationRegressionData.latest.verified.txt | 12 + ...pshotTests.VerifyChangeModels.verified.txt | 12 + ...elSnapshotTests.VerifyDbModel.verified.txt | 20 + ...sts.VerifyIObjectWithIdModels.verified.txt | 4 + .../LcmCrdt.Tests/MiniLcmTests/PluginTests.cs | 129 +++ .../LcmCrdt/Changes/CreatePluginChange.cs | 34 + .../LcmCrdt/Changes/EditPluginChange.cs | 32 + backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 48 + .../FwLite/LcmCrdt/Data/MiniLcmRepository.cs | 6 + backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs | 1 + backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 4 + .../20260706195630_AddPlugins.Designer.cs | 1025 +++++++++++++++++ .../Migrations/20260706195630_AddPlugins.cs | 49 + .../LcmCrdtDbContextModelSnapshot.cs | 36 + backend/FwLite/MiniLcm/IMiniLcmReadApi.cs | 8 + backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs | 15 + .../FwLite/MiniLcm/Models/IObjectWithId.cs | 1 + backend/FwLite/MiniLcm/Models/Plugin.cs | 28 + .../MiniLcmApiWriteNormalizationWrapper.cs | 20 + .../Validators/MiniLcmApiValidationWrapper.cs | 12 + .../MiniLcm/Validators/MiniLcmValidators.cs | 7 + .../MiniLcm/Validators/PluginValidator.cs | 20 + frontend/viewer/src/ShadcnProjectView.svelte | 10 + .../FwLiteShared/Services/IMiniLcmFeatures.ts | 1 + .../Services/IMiniLcmJsInvokable.ts | 6 + .../generated-types/MiniLcm/Models/IPlugin.ts | 15 + frontend/viewer/src/lib/dotnet-types/index.ts | 1 + .../lib/plugins/PluginAiPromptDialog.svelte | 80 ++ .../src/lib/plugins/PluginEditorDialog.svelte | 135 +++ .../src/lib/plugins/PluginRunView.svelte | 189 +++ .../plugins/PluginWriteConfirmDialog.svelte | 51 + .../viewer/src/lib/plugins/PluginsView.svelte | 153 +++ frontend/viewer/src/lib/plugins/README.md | 64 + .../plugins/examples/dictionary-stats.html | 442 +++++++ .../src/lib/plugins/examples/flashcards.html | 472 ++++++++ .../viewer/src/lib/plugins/examples/index.ts | 34 + .../lib/plugins/examples/word-collector.html | 455 ++++++++ .../src/lib/plugins/plugin-api-adapter.ts | 266 +++++ .../src/lib/plugins/plugin-api-types.ts | 97 ++ .../viewer/src/lib/plugins/plugin-host.ts | 86 ++ .../src/lib/plugins/plugin-local-data.ts | 84 ++ .../viewer/src/lib/plugins/plugin-prompt.ts | 182 +++ frontend/viewer/src/lib/plugins/plugin-sdk.js | 105 ++ .../src/lib/plugins/plugin-srcdoc.test.ts | 69 ++ .../viewer/src/lib/plugins/plugin-srcdoc.ts | 48 + .../src/lib/services/feature-service.ts | 3 + frontend/viewer/src/locales/en.po | 245 ++++ frontend/viewer/src/locales/es.po | 209 ++++ frontend/viewer/src/locales/fr.po | 209 ++++ frontend/viewer/src/locales/id.po | 209 ++++ frontend/viewer/src/locales/ko.po | 209 ++++ frontend/viewer/src/locales/ms.po | 209 ++++ frontend/viewer/src/locales/sw.po | 209 ++++ frontend/viewer/src/locales/vi.po | 209 ++++ .../viewer/src/project/ProjectSidebar.svelte | 5 +- .../src/project/data/plugin-service.svelte.ts | 55 + .../src/project/demo/in-memory-demo-api.ts | 37 + frontend/viewer/tests/plugins.test.ts | 95 ++ 62 files changed, 6542 insertions(+), 3 deletions(-) create mode 100644 backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs create mode 100644 backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs create mode 100644 backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.Designer.cs create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.cs create mode 100644 backend/FwLite/MiniLcm/Models/Plugin.cs create mode 100644 backend/FwLite/MiniLcm/Validators/PluginValidator.cs create mode 100644 frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts create mode 100644 frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte create mode 100644 frontend/viewer/src/lib/plugins/PluginEditorDialog.svelte create mode 100644 frontend/viewer/src/lib/plugins/PluginRunView.svelte create mode 100644 frontend/viewer/src/lib/plugins/PluginWriteConfirmDialog.svelte create mode 100644 frontend/viewer/src/lib/plugins/PluginsView.svelte create mode 100644 frontend/viewer/src/lib/plugins/README.md create mode 100644 frontend/viewer/src/lib/plugins/examples/dictionary-stats.html create mode 100644 frontend/viewer/src/lib/plugins/examples/flashcards.html create mode 100644 frontend/viewer/src/lib/plugins/examples/index.ts create mode 100644 frontend/viewer/src/lib/plugins/examples/word-collector.html create mode 100644 frontend/viewer/src/lib/plugins/plugin-api-adapter.ts create mode 100644 frontend/viewer/src/lib/plugins/plugin-api-types.ts create mode 100644 frontend/viewer/src/lib/plugins/plugin-host.ts create mode 100644 frontend/viewer/src/lib/plugins/plugin-local-data.ts create mode 100644 frontend/viewer/src/lib/plugins/plugin-prompt.ts create mode 100644 frontend/viewer/src/lib/plugins/plugin-sdk.js create mode 100644 frontend/viewer/src/lib/plugins/plugin-srcdoc.test.ts create mode 100644 frontend/viewer/src/lib/plugins/plugin-srcdoc.ts create mode 100644 frontend/viewer/src/project/data/plugin-service.svelte.ts create mode 100644 frontend/viewer/tests/plugins.test.ts diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index a5fcfffb44..4173ca8efe 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -21,14 +21,14 @@ MiniLcmApiUserFacingWrappers userFacingWrappers { private readonly IMiniLcmApi _wrappedApi = userFacingWrappers.Apply(api, project, notificationWrapperFactory); - public record MiniLcmFeatures(bool? History, bool? Write, bool? OpenWithFlex, bool? Feedback, bool? Sync, bool? Audio, bool? CustomViews, bool? Comments); + public record MiniLcmFeatures(bool? History, bool? Write, bool? OpenWithFlex, bool? Feedback, bool? Sync, bool? Audio, bool? CustomViews, bool? Comments, bool? Plugins); private bool SupportsSync => project.DataFormat == ProjectDataFormat.Harmony && api is CrdtMiniLcmApi; [JSInvokable] public MiniLcmFeatures SupportedFeatures() { var isCrdtProject = project.DataFormat == ProjectDataFormat.Harmony; var isFwDataProject = project.DataFormat == ProjectDataFormat.FwData; - return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject, Comments: isCrdtProject); + return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject, Comments: isCrdtProject, Plugins: isCrdtProject); } private bool CanWrite => @@ -270,6 +270,42 @@ public async Task DeleteCustomView(Guid id) OnDataChanged(); } + [JSInvokable] + public ValueTask GetPlugins() + { + return _wrappedApi.GetPlugins().ToArrayAsync(); + } + + [JSInvokable] + [TsFunction(Type = "Promise")] + public Task GetPlugin(Guid id) + { + return _wrappedApi.GetPlugin(id); + } + + [JSInvokable] + public async Task CreatePlugin(Plugin plugin) + { + var createdPlugin = await _wrappedApi.CreatePlugin(plugin); + OnDataChanged(); + return createdPlugin; + } + + [JSInvokable] + public async Task UpdatePlugin(Plugin plugin) + { + var updatedPlugin = await _wrappedApi.UpdatePlugin(plugin); + OnDataChanged(); + return updatedPlugin; + } + + [JSInvokable] + public async Task DeletePlugin(Guid id) + { + await _wrappedApi.DeletePlugin(id); + OnDataChanged(); + } + [JSInvokable] public ValueTask GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false) { diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt index d9295d1c9a..bf4c634575 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt @@ -1346,5 +1346,21 @@ { "$type": "delete:UserComment", "EntityId": "4be242d0-b1a6-986f-cc0d-b35860e69e60" + }, + { + "$type": "CreatePluginChange", + "Name": "Lodge", + "Html": "Bedfordshire", + "EntityId": "f2dd8f6b-ec81-d71e-5849-e1756fbac139" + }, + { + "$type": "EditPluginChange", + "Name": "Ergonomic Frozen Cheese", + "Html": "Flats", + "EntityId": "3c4f92b1-e0c2-edf0-b407-d0cea075af0a" + }, + { + "$type": "delete:Plugin", + "EntityId": "26accf27-93f5-f767-621a-563c58379c6a" } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs index 247ca900c3..9e7c367b03 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs @@ -318,6 +318,23 @@ customView with }); yield return new ChangeWithDependencies(editCustomViewChange, [createCustomViewChange]); + var plugin = new Plugin + { + Id = Guid.NewGuid(), + Name = "Test Plugin", + Html = "

Hello

", + }; + var createPluginChange = new CreatePluginChange(plugin.Id, plugin); + yield return new ChangeWithDependencies(createPluginChange); + var editPluginChange = new EditPluginChange( + plugin.Id, + plugin with + { + Name = "Updated Plugin", + Html = "

Updated

", + }); + yield return new ChangeWithDependencies(editPluginChange, [createPluginChange]); + var commentThread = new CommentThread { Id = Guid.NewGuid(), diff --git a/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs b/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs index ecc27e0cef..9836d173d9 100644 --- a/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs @@ -15,6 +15,7 @@ public class ConfigRegistrationTests typeof(JsonPatchChange), //not supported typeof(JsonPatchChange), //replaced by JsonPatchExampleSentenceChange typeof(JsonPatchChange), //not supported. Use EditCustomViewChange + typeof(JsonPatchChange), //not supported. Use EditPluginChange typeof(JsonPatchChange), //not supported. Use SetCommentThreadStatusChange typeof(JsonPatchChange), //not supported. Use EditUserCommentChange typeof(DeleteChange), //MorphTypes cannot be deleted diff --git a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt index da0cb7652f..986f284354 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt @@ -4089,5 +4089,17 @@ }, "Id": "4f5808a8-bbf1-e23d-e5fe-5a997f13bf3a", "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "Plugin", + "Id": "1673f062-9249-8b83-8641-3b6ce90186b9", + "DeletedAt": null, + "Name": "collaborative", + "Html": "wireless" + }, + "Id": "1673f062-9249-8b83-8641-3b6ce90186b9", + "DeletedAt": null } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index 4d9da1f85e..f6633ce6f4 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -220,6 +220,18 @@ DerivedType: DeleteChange, TypeDiscriminator: delete:CustomView }, + { + DerivedType: CreatePluginChange, + TypeDiscriminator: CreatePluginChange + }, + { + DerivedType: EditPluginChange, + TypeDiscriminator: EditPluginChange + }, + { + DerivedType: DeleteChange, + TypeDiscriminator: delete:Plugin + }, { DerivedType: CreateCommentThreadChange, TypeDiscriminator: CreateCommentThreadChange diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index d17e1227f1..f2c60178e7 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -303,6 +303,26 @@ Relational:TableName: PartOfSpeech Relational:ViewName: Relational:ViewSchema: + EntityType: Plugin + Properties: + Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd + DeletedAt (DateTimeOffset?) + Html (string) Required + Name (string) Required + SnapshotId (no field, Guid?) Shadow FK Index + Keys: + Id PK + Foreign keys: + Plugin {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull + Indexes: + SnapshotId Unique + Annotations: + Relational:FunctionName: + Relational:Schema: + Relational:SqlQuery: + Relational:TableName: Plugin + Relational:ViewName: + Relational:ViewSchema: EntityType: Publication Properties: Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt index c94eb71fdf..d69e09475d 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt @@ -40,6 +40,10 @@ DerivedType: CustomView, TypeDiscriminator: CustomView }, + { + DerivedType: Plugin, + TypeDiscriminator: Plugin + }, { DerivedType: CommentThread, TypeDiscriminator: CommentThread diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs new file mode 100644 index 0000000000..6a51c5d08b --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs @@ -0,0 +1,129 @@ +using FluentValidation; +using MiniLcm.Tests; + +namespace LcmCrdt.Tests.MiniLcmTests; + +public class PluginTests(MiniLcmApiFixture fixture) : IClassFixture +{ + private const string ManagerUserId = "manager-user"; + private const string EditorUserId = "editor-user"; + + private IMiniLcmApi? _api; + // The full production wrapper stack, not the raw fixture.Api — plugin writes must be + // explicitly forwarded through the normalization/validation wrappers, and only a wrapped + // API exercises that. + private IMiniLcmApi Api => _api ??= TestMiniLcmWrappers.CreateUserFacingWrappers().Apply(fixture.Api, null!); + + private async Task SetCurrentUser(string userId, UserProjectRole role) + { + var projectService = fixture.GetService(); + await projectService.UpdateLastUser("test-user", userId); + await projectService.UpdateUserRole(role); + } + + private Plugin NewPlugin(string name) + { + return new() + { + Id = Guid.NewGuid(), + Name = name, + Html = "

Test plugin

", + }; + } + + private async Task CreatePluginAsManager(string name = "Owned") + { + await SetCurrentUser(ManagerUserId, UserProjectRole.Manager); + return await Api.CreatePlugin(NewPlugin(name)); + } + + [Fact] + public async Task CreatePlugin_AllowsManager() + { + await SetCurrentUser(ManagerUserId, UserProjectRole.Manager); + + var created = await Api.CreatePlugin(NewPlugin("My Plugin")); + + var allPlugins = await Api.GetPlugins().ToArrayAsync(); + allPlugins.Should().Contain(p => p.Id == created.Id && p.Name == "My Plugin"); + } + + [Fact] + public async Task CreatePlugin_RejectsEditor() + { + await SetCurrentUser(EditorUserId, UserProjectRole.Editor); + await Assert.ThrowsAsync(() => Api.CreatePlugin(NewPlugin("Should Fail"))); + } + + [Fact] + public async Task CreatePlugin_RejectsEmptyName() + { + await SetCurrentUser(ManagerUserId, UserProjectRole.Manager); + var plugin = NewPlugin(" "); + await Assert.ThrowsAsync(() => Api.CreatePlugin(plugin)); + } + + [Fact] + public async Task CreatePlugin_RejectsEmptyHtml() + { + await SetCurrentUser(ManagerUserId, UserProjectRole.Manager); + var plugin = NewPlugin("My Plugin") with { Html = "" }; + await Assert.ThrowsAsync(() => Api.CreatePlugin(plugin)); + } + + [Fact] + public async Task UpdatePlugin_AllowsManager() + { + var created = await CreatePluginAsManager("My Plugin"); + await Api.UpdatePlugin(created with { Name = "My Updated Plugin", Html = "v2" }); + + var fetched = await Api.GetPlugin(created.Id); + fetched.Should().NotBeNull(); + fetched.Name.Should().Be("My Updated Plugin"); + fetched.Html.Should().Be("v2"); + } + + [Fact] + public async Task UpdatePlugin_RejectsEditor() + { + var created = await CreatePluginAsManager(); + + await SetCurrentUser(EditorUserId, UserProjectRole.Editor); + await Assert.ThrowsAsync(() => + Api.UpdatePlugin(created with { Name = "Should Fail" })); + } + + [Fact] + public async Task DeletePlugin_AllowsManager() + { + var plugin = await CreatePluginAsManager(); + + await SetCurrentUser(ManagerUserId, UserProjectRole.Manager); + await Api.DeletePlugin(plugin.Id); + + var deleted = await Api.GetPlugin(plugin.Id); + deleted.Should().BeNull(); + } + + [Fact] + public async Task DeletePlugin_RejectsEditor() + { + var plugin = await CreatePluginAsManager(); + + await SetCurrentUser(EditorUserId, UserProjectRole.Editor); + await Assert.ThrowsAsync(() => Api.DeletePlugin(plugin.Id)); + } + + [Fact] + public async Task GetPlugins_ReturnsAllPlugins_ForEditor() + { + var plugin1 = await CreatePluginAsManager("Plugin 1"); + var plugin2 = await CreatePluginAsManager("Plugin 2"); + + await SetCurrentUser(EditorUserId, UserProjectRole.Editor); + var visible = await Api.GetPlugins().ToArrayAsync(); + + visible.Should().Contain(p => p.Id == plugin1.Id); + visible.Should().Contain(p => p.Id == plugin2.Id); + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs b/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs new file mode 100644 index 0000000000..4460f27de4 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using SIL.Harmony; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Entities; + +namespace LcmCrdt.Changes; + +public class CreatePluginChange : CreateChange, ISelfNamedType +{ + public CreatePluginChange(Guid entityId, Plugin plugin) : base(entityId) + { + Name = plugin.Name; + Html = plugin.Html; + } + + [JsonConstructor] + public CreatePluginChange(Guid entityId) : base(entityId) + { + } + + public string Name { get; set; } = string.Empty; + public string Html { get; set; } = string.Empty; + + public override ValueTask NewEntity(Commit commit, IChangeContext context) + { + return ValueTask.FromResult(new Plugin + { + Id = EntityId, + Name = Name, + Html = Html + }); + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs b/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs new file mode 100644 index 0000000000..f72785c1d1 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Entities; + +namespace LcmCrdt.Changes; + +public class EditPluginChange : EditChange, ISelfNamedType +{ + [SetsRequiredMembers] + public EditPluginChange(Guid entityId, Plugin plugin) : base(entityId) + { + Name = plugin.Name; + Html = plugin.Html; + } + + [JsonConstructor] + private EditPluginChange(Guid entityId) : base(entityId) + { + } + + public required string Name { get; set; } + public required string Html { get; set; } + + public override ValueTask ApplyChange(Plugin entity, IChangeContext context) + { + entity.Name = Name; + entity.Html = Html; + return ValueTask.CompletedTask; + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 72e3f85cc8..a2b3550882 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -1068,6 +1068,54 @@ private void AssertManagerRoleForCustomViewWrite() $"Only managers can manage custom views."); } + public async IAsyncEnumerable GetPlugins() + { + await using var repo = await repoFactory.CreateRepoAsync(); + await foreach (var plugin in repo.Plugins.AsAsyncEnumerable()) + { + yield return plugin; + } + } + + public async Task GetPlugin(Guid id) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetPlugin(id); + } + + public async Task CreatePlugin(Plugin plugin) + { + AssertManagerRoleForPluginWrite(); + if (plugin.Id == Guid.Empty) plugin.Id = Guid.NewGuid(); + await AddChange(new CreatePluginChange(plugin.Id, plugin)); + return await GetPlugin(plugin.Id) ?? throw NotFoundException.ForType(plugin.Id); + } + + public async Task UpdatePlugin(Plugin plugin) + { + AssertManagerRoleForPluginWrite(); + await using var repo = await repoFactory.CreateRepoAsync(); + var id = plugin.Id; + _ = await repo.GetPlugin(id) ?? throw NotFoundException.ForType(id); + await AddChange(new EditPluginChange(id, plugin)); + return await repo.GetPlugin(id) ?? throw NotFoundException.ForType(id); + } + + public async Task DeletePlugin(Guid id) + { + AssertManagerRoleForPluginWrite(); + await using var repo = await repoFactory.CreateRepoAsync(); + _ = await repo.GetPlugin(id) ?? throw NotFoundException.ForType(id); + await AddChange(new DeleteChange(id)); + } + + private void AssertManagerRoleForPluginWrite() + { + if (ProjectData.Role == UserProjectRole.Manager) return; + throw new UnauthorizedAccessException( + $"Only managers can manage plugins."); + } + public async IAsyncEnumerable GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false) { await using var repo = await repoFactory.CreateRepoAsync(); diff --git a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs index afbfe4aa2c..4c141d0875 100644 --- a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs +++ b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs @@ -75,6 +75,7 @@ public void Dispose() public IQueryable PartsOfSpeech => dbContext.PartsOfSpeech; public IQueryable Publications => dbContext.Publications; public IQueryable CustomViews => dbContext.CustomViews; + public IQueryable Plugins => dbContext.Plugins; public IQueryable CommentThreads => dbContext.CommentThreads; public IQueryable UserComments => dbContext.UserComments; @@ -317,6 +318,11 @@ public async Task GetEntryIndex(Guid entryId, string? query = null, IndexQu return customView; } + public async Task GetPlugin(Guid pluginId) + { + return await AsyncExtensions.SingleOrDefaultAsync(Plugins.AsQueryable(), p => p.Id == pluginId); + } + public async Task GetCommentThread(Guid threadId) { return await AsyncExtensions.SingleOrDefaultAsync(CommentThreads.AsQueryable(), t => t.Id == threadId); diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index e1fcf6eac3..4d7c685b56 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -30,6 +30,7 @@ IOptions options public IQueryable PartsOfSpeech => Set().AsNoTracking(); public IQueryable Publications => Set().AsNoTracking(); public IQueryable CustomViews => Set().AsNoTracking(); + public IQueryable Plugins => Set().AsNoTracking(); public IQueryable CommentThreads => Set().AsNoTracking(); public IQueryable UserComments => Set().AsNoTracking(); public DbSet UnreadComments => Set(); diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 970116a127..6b37b10adf 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -289,6 +289,7 @@ public static void ConfigureCrdt(CrdtConfig config) .HasColumnType("jsonb") .HasConversion(writingSystemArrayConverter); }) + .Add() .Add(builder => { builder.HasIndex(t => new { t.SubjectType, t.SubjectId }); @@ -380,6 +381,9 @@ public static void ConfigureCrdt(CrdtConfig config) .Add() .Add() .Add>() + .Add() + .Add() + .Add>() .Add() .Add() .Add() diff --git a/backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.Designer.cs new file mode 100644 index 0000000000..af23f7f148 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.Designer.cs @@ -0,0 +1,1025 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260706195630_AddPlugins")] + partial class AddPlugins + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("LcmCrdt.Data.UnreadComment", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("MarkedUnreadAt") + .HasColumnType("TEXT"); + + b.HasKey("CommentId"); + + b.HasIndex("CommentThreadId"); + + b.ToTable("UnreadComments"); + }); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("CommentThread"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Analysis") + .HasColumnType("jsonb"); + + b.Property("Base") + .HasColumnType("INTEGER"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExampleFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SenseFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Vernacular") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("CustomView"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("HomographNumber") + .HasColumnType("INTEGER"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Postfix") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SecondaryOrder") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Kind") + .IsUnique(); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("MorphType"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Plugin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Html") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Plugin"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsMain") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("Pictures") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("PreviousCommentId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommentThreadId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("UserComment"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + + b.HasAnnotation("CustomIndex:CompositeIndexes", "[{\"paths\":[\"HybridDateTime.DateTime\",\"HybridDateTime.Counter\",\"Id\"],\"unique\":false,\"name\":\"IX_Commits_DateTime_Counter_Id\"}]"); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CommentThread", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CustomView", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.MorphType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Plugin", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Plugin", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.HasOne("MiniLcm.Models.CommentThread", null) + .WithMany("Comments") + .HasForeignKey("CommentThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.UserComment", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Navigation("Comments"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.cs b/backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.cs new file mode 100644 index 0000000000..5680edc7fe --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260706195630_AddPlugins.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class AddPlugins : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Plugin", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DeletedAt = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", nullable: false), + Html = table.Column(type: "TEXT", nullable: false), + SnapshotId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Plugin", x => x.Id); + table.ForeignKey( + name: "FK_Plugin_Snapshots_SnapshotId", + column: x => x.SnapshotId, + principalTable: "Snapshots", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_Plugin_SnapshotId", + table: "Plugin", + column: "SnapshotId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Plugin"); + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs index ae72a45791..4bc15f61c8 100644 --- a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs +++ b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs @@ -439,6 +439,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PartOfSpeech"); }); + modelBuilder.Entity("MiniLcm.Models.Plugin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Html") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Plugin"); + }); + modelBuilder.Entity("MiniLcm.Models.Publication", b => { b.Property("Id") @@ -868,6 +896,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity("MiniLcm.Models.Plugin", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Plugin", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity("MiniLcm.Models.Publication", b => { b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 827482aa83..9780fdda76 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -49,6 +49,14 @@ IAsyncEnumerable GetCustomViews() { throw new NotSupportedException("Custom views are only supported by CRDT projects"); } + IAsyncEnumerable GetPlugins() + { + throw new NotSupportedException("Plugins are only supported by CRDT projects"); + } + Task GetPlugin(Guid id) + { + throw new NotSupportedException("Plugins are only supported by CRDT projects"); + } IAsyncEnumerable GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false) { throw new NotSupportedException("Comments are only supported by CRDT projects"); diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index a09f955573..ce7e074d15 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -174,6 +174,21 @@ Task DeleteCustomView(Guid id) } #endregion + #region Plugin + Task CreatePlugin(Plugin plugin) + { + throw new NotSupportedException("Plugins are only supported by CRDT projects"); + } + Task UpdatePlugin(Plugin plugin) + { + throw new NotSupportedException("Plugins are only supported by CRDT projects"); + } + Task DeletePlugin(Guid id) + { + throw new NotSupportedException("Plugins are only supported by CRDT projects"); + } + #endregion + #region Comments Task CreateCommentThread(CommentThread thread, UserComment firstComment) { diff --git a/backend/FwLite/MiniLcm/Models/IObjectWithId.cs b/backend/FwLite/MiniLcm/Models/IObjectWithId.cs index 4fd7db364a..cb3613a254 100644 --- a/backend/FwLite/MiniLcm/Models/IObjectWithId.cs +++ b/backend/FwLite/MiniLcm/Models/IObjectWithId.cs @@ -13,6 +13,7 @@ namespace MiniLcm.Models; [JsonDerivedType(typeof(ComplexFormType), nameof(ComplexFormType))] [JsonDerivedType(typeof(ComplexFormComponent), nameof(ComplexFormComponent))] [JsonDerivedType(typeof(CustomView), nameof(CustomView))] +[JsonDerivedType(typeof(Plugin), nameof(Plugin))] [JsonDerivedType(typeof(CommentThread), nameof(CommentThread))] [JsonDerivedType(typeof(UserComment), nameof(UserComment))] [JsonDerivedType(typeof(MorphType), nameof(MorphType))] diff --git a/backend/FwLite/MiniLcm/Models/Plugin.cs b/backend/FwLite/MiniLcm/Models/Plugin.cs new file mode 100644 index 0000000000..9316351eeb --- /dev/null +++ b/backend/FwLite/MiniLcm/Models/Plugin.cs @@ -0,0 +1,28 @@ +namespace MiniLcm.Models; + +/// +/// A user-authored, project-scoped plugin: a self-contained HTML document that FW Lite runs +/// in a sandboxed iframe. Stored in the CRDT so it syncs to teammates; never synced to FwData. +/// +public record Plugin : IObjectWithId +{ + public Guid Id { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public required string Name { get; set; } + public required string Html { get; set; } + + public Guid[] GetReferences() + { + return []; + } + + public void RemoveReference(Guid id, DateTimeOffset time) + { + } + + public Plugin Copy() + { + return this with { }; + } +} diff --git a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs index a43847bb50..af87906304 100644 --- a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs +++ b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs @@ -532,6 +532,26 @@ public Task DeleteCustomView(Guid id) #endregion + #region Plugin + + // Plugin data is an HTML document plus a name, not user-entered linguistic text, so no normalization is applied. + public async Task CreatePlugin(Plugin plugin) + { + return await _api.CreatePlugin(plugin); + } + + public async Task UpdatePlugin(Plugin plugin) + { + return await _api.UpdatePlugin(plugin); + } + + public Task DeletePlugin(Guid id) + { + return _api.DeletePlugin(id); + } + + #endregion + #region Comments public Task CreateCommentThread(CommentThread thread, UserComment firstComment) diff --git a/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs b/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs index db2019ae15..419d373cf8 100644 --- a/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs +++ b/backend/FwLite/MiniLcm/Validators/MiniLcmApiValidationWrapper.cs @@ -75,6 +75,18 @@ private async Task ThrowIfAnotherMainExists(Guid id) return null; } + public async Task CreatePlugin(Plugin plugin) + { + await validators.ValidateAndThrow(plugin); + return await _api.CreatePlugin(plugin); + } + + public async Task UpdatePlugin(Plugin plugin) + { + await validators.ValidateAndThrow(plugin); + return await _api.UpdatePlugin(plugin); + } + public async Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) { await validators.ValidateAndThrow(writingSystem); diff --git a/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs b/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs index 4d516f6583..779335dcb1 100644 --- a/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs +++ b/backend/FwLite/MiniLcm/Validators/MiniLcmValidators.cs @@ -16,6 +16,7 @@ public record MiniLcmValidators( IValidator PartOfSpeechValidator, IValidator SemanticDomainValidator, IValidator PublicationValidator, + IValidator PluginValidator, IValidator> MorphTypeUpdateValidator, IValidator> WritingSystemUpdateValidator, IValidator> PublicationUpdateValidator) @@ -65,6 +66,11 @@ public async Task ValidateAndThrow(Publication value) await PublicationValidator.ValidateAndThrowAsync(value); } + public async Task ValidateAndThrow(Plugin value) + { + await PluginValidator.ValidateAndThrowAsync(value); + } + public async Task ValidateAndThrow(UpdateObjectInput update) { await MorphTypeUpdateValidator.ValidateAndThrowAsync(update); @@ -96,6 +102,7 @@ public static IServiceCollection AddMiniLcmValidators(this IServiceCollection se services.AddTransient, PartOfSpeechValidator>(); services.AddTransient, SemanticDomainValidator>(); services.AddTransient, PublicationValidator>(); + services.AddTransient, PluginValidator>(); services.AddTransient>, MorphTypeUpdateValidator>(); services.AddTransient>, WritingSystemUpdateValidator>(); services.AddTransient>, PublicationUpdateValidator>(); diff --git a/backend/FwLite/MiniLcm/Validators/PluginValidator.cs b/backend/FwLite/MiniLcm/Validators/PluginValidator.cs new file mode 100644 index 0000000000..aca3a38c9e --- /dev/null +++ b/backend/FwLite/MiniLcm/Validators/PluginValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using MiniLcm.Models; + +namespace MiniLcm.Validators; + +public class PluginValidator : AbstractValidator +{ + // Caps the commit payload so a single plugin can't bloat sync for the whole project. + public const int MaxHtmlLength = 5_000_000; + + public PluginValidator() + { + RuleFor(p => p.DeletedAt).Null(); + RuleFor(p => p.Name).Must(name => !string.IsNullOrWhiteSpace(name)) + .WithMessage("Plugin name is required"); + RuleFor(p => p.Html).Must(html => !string.IsNullOrWhiteSpace(html)) + .WithMessage("Plugin HTML is required") + .MaximumLength(MaxHtmlLength); + } +} diff --git a/frontend/viewer/src/ShadcnProjectView.svelte b/frontend/viewer/src/ShadcnProjectView.svelte index eb2b167e33..22032e21c9 100644 --- a/frontend/viewer/src/ShadcnProjectView.svelte +++ b/frontend/viewer/src/ShadcnProjectView.svelte @@ -22,6 +22,8 @@ import DialogsProvider from '$lib/DialogsProvider.svelte'; import {navigate, Route, useRouter} from 'svelte-routing'; import ActivityView from '$lib/activity/ActivityView.svelte'; + import PluginsView from '$lib/plugins/PluginsView.svelte'; + import PluginRunView from '$lib/plugins/PluginRunView.svelte'; import {AppNotification} from '$lib/notifications/notifications'; import type {HTMLAttributes} from 'svelte/elements'; import {useIdleService} from '$lib/services/idle-service'; @@ -77,6 +79,14 @@ + + + + + {#key params.id} + + {/key} + {setTimeout(() => navigate(`${$base.uri}/browse`, {replace: true}))} diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmFeatures.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmFeatures.ts index 34e48f2f36..c477ba96fb 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmFeatures.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmFeatures.ts @@ -13,5 +13,6 @@ export interface IMiniLcmFeatures audio?: boolean; customViews?: boolean; comments?: boolean; + plugins?: boolean; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts index 805d4a62fb..d2e095a151 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/IMiniLcmJsInvokable.ts @@ -17,6 +17,7 @@ import type {IEntry} from '../../MiniLcm/Models/IEntry'; import type {IQueryOptions} from '../../MiniLcm/IQueryOptions'; import type {IWritingSystem} from '../../MiniLcm/Models/IWritingSystem'; import type {WritingSystemType} from '../../MiniLcm/Models/WritingSystemType'; +import type {IPlugin} from '../../MiniLcm/Models/IPlugin'; import type {ICommentThread} from '../../MiniLcm/Models/ICommentThread'; import type {SubjectType} from '../../MiniLcm/Models/SubjectType'; import type {IUserComment} from '../../MiniLcm/Models/IUserComment'; @@ -64,6 +65,11 @@ export interface IMiniLcmJsInvokable createCustomView(customView: ICustomView) : Promise; updateCustomView(customView: ICustomView) : Promise; deleteCustomView(id: string) : Promise; + getPlugins() : Promise; + getPlugin(id: string) : Promise; + createPlugin(plugin: IPlugin) : Promise; + updatePlugin(plugin: IPlugin) : Promise; + deletePlugin(id: string) : Promise; getCommentThreads(subjectType: SubjectType, subjectId: string, includeComments?: boolean) : Promise; getCommentThread(id: string) : Promise; getUserComments(threadId: string) : Promise; diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts new file mode 100644 index 0000000000..5599caade6 --- /dev/null +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts @@ -0,0 +1,15 @@ +/* eslint-disable */ +// This code was generated by a Reinforced.Typings tool. +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. + +import type {IObjectWithId} from './IObjectWithId'; + +export interface IPlugin extends IObjectWithId +{ + id: string; + deletedAt?: string; + name: string; + html: string; +} +/* eslint-enable */ diff --git a/frontend/viewer/src/lib/dotnet-types/index.ts b/frontend/viewer/src/lib/dotnet-types/index.ts index 82ad4deef2..f0f342dd25 100644 --- a/frontend/viewer/src/lib/dotnet-types/index.ts +++ b/frontend/viewer/src/lib/dotnet-types/index.ts @@ -28,6 +28,7 @@ export * from './generated-types/MiniLcm/Models/ProjectDataFormat'; export * from './generated-types/MiniLcm/Models/WritingSystemType'; export * from './generated-types/MiniLcm/Models/MorphTypeKind'; export * from './generated-types/MiniLcm/Models/ICustomView'; +export * from './generated-types/MiniLcm/Models/IPlugin'; export * from './generated-types/MiniLcm/Models/IViewField'; export * from './generated-types/MiniLcm/Models/IViewWritingSystem'; export * from './generated-types/MiniLcm/Models/ViewBase'; diff --git a/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte b/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte new file mode 100644 index 0000000000..2b1a920146 --- /dev/null +++ b/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte @@ -0,0 +1,80 @@ + + + + + + {$t`Create a plugin with AI`} + + {$t`No coding needed — an AI assistant can write the plugin for you.`} + + + +
    +
  1. {$t`Copy the prompt below. It already contains everything the AI needs to know about plugins and this project.`}
  2. +
  3. {$t`Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want.`}
  4. +
  5. {$t`Copy the HTML file the AI produces, then add it here via “New plugin”.`}
  6. +
+ + {#if prompt} + + +
+ + +
+
+ + +
+
+ +
Enter submits · Shift+Enter for a new line in the sentence
+ + +
+
+
🎉
+

All caught up!

+

+
+
0
added this session
+
0
day streak
+
+ +
+
+ + + + + diff --git a/frontend/viewer/src/lib/plugins/examples/word-harvest-bingo.html b/frontend/viewer/src/lib/plugins/examples/word-harvest-bingo.html new file mode 100644 index 0000000000..f6c8af040d --- /dev/null +++ b/frontend/viewer/src/lib/plugins/examples/word-harvest-bingo.html @@ -0,0 +1,653 @@ + + + + + +Word Harvest Bingo + + + + +
+
+
Loading semantic domains…
+
+ + + + + +
+
+

Word Harvest Bingo

+
+ live + + +
+
+ +
+ +
+ + +
+ + + + diff --git a/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts b/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts new file mode 100644 index 0000000000..0832d1023d --- /dev/null +++ b/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from 'vitest'; +import {toGridifyFilter} from './plugin-api-adapter'; +import {PluginApiException} from './plugin-api-types'; + +const POS_ID = '86ff66f6-0774-407a-a0dc-3eeaf873daf7'; + +describe('toGridifyFilter', () => { + it('returns undefined for no filter or an empty filter', () => { + expect(toGridifyFilter(undefined)).toBeUndefined(); + expect(toGridifyFilter({})).toBeUndefined(); + }); + + it('translates existing fields', () => { + expect(toGridifyFilter({semanticDomainCode: '2.1.1'})) + .toEqual({gridifyFilter: 'Senses.SemanticDomains.Code=2.1.1'}); + expect(toGridifyFilter({partOfSpeechId: POS_ID})) + .toEqual({gridifyFilter: `Senses.PartOfSpeechId=${POS_ID}`}); + }); + + it('translates missingGlossWs', () => { + expect(toGridifyFilter({missingGlossWs: 'en'})) + .toEqual({gridifyFilter: '(Senses=null|Senses.Gloss[en]=)'}); + }); + + it('translates missingExampleWs', () => { + expect(toGridifyFilter({missingExampleWs: 'seh-fonipa'})) + .toEqual({gridifyFilter: '(Senses.ExampleSentences=null|Senses.ExampleSentences.Sentence[seh-fonipa]=)'}); + }); + + it('translates missingPartOfSpeech only when true', () => { + expect(toGridifyFilter({missingPartOfSpeech: true})) + .toEqual({gridifyFilter: 'Senses.PartOfSpeechId='}); + expect(toGridifyFilter({missingPartOfSpeech: false})).toBeUndefined(); + }); + + it('joins multiple conditions with AND', () => { + expect(toGridifyFilter({semanticDomainCode: '1', missingGlossWs: 'en', missingPartOfSpeech: true})) + .toEqual({gridifyFilter: 'Senses.SemanticDomains.Code=1,(Senses=null|Senses.Gloss[en]=),Senses.PartOfSpeechId='}); + }); + + it('rejects invalid writing system codes', () => { + expect(() => toGridifyFilter({missingGlossWs: 'en; DROP'})).toThrow(PluginApiException); + expect(() => toGridifyFilter({missingGlossWs: '1en'})).toThrow(/writing system/); + expect(() => toGridifyFilter({missingExampleWs: ''})).toThrow(PluginApiException); + }); + + it('rejects invalid semantic domain codes and part of speech ids', () => { + expect(() => toGridifyFilter({semanticDomainCode: 'abc'})).toThrow(PluginApiException); + expect(() => toGridifyFilter({partOfSpeechId: 'not-a-guid'})).toThrow(PluginApiException); + }); +}); diff --git a/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts b/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts index 90fc36ff60..fa92a3db87 100644 --- a/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts +++ b/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts @@ -127,7 +127,7 @@ function toQueryOptions(query: PluginEntryQuery): IQueryOptions { * Plugins only get structured filters; the gridify string syntax stays an internal detail. * Inputs are strictly validated since they end up inside a query expression. */ -function toGridifyFilter(filter: PluginEntryFilter | undefined): {gridifyFilter: string} | undefined { +export function toGridifyFilter(filter: PluginEntryFilter | undefined): {gridifyFilter: string} | undefined { if (!filter) return undefined; const parts: string[] = []; if (filter.semanticDomainCode !== undefined) { @@ -139,10 +139,27 @@ function toGridifyFilter(filter: PluginEntryFilter | undefined): {gridifyFilter: if (filter.partOfSpeechId !== undefined) { parts.push(`Senses.PartOfSpeechId=${asId(filter.partOfSpeechId)}`); } + // The missing-* expressions mirror the "provide-missing" task queries in tasks-service.ts. + if (filter.missingGlossWs !== undefined) { + parts.push(`(Senses=null|Senses.Gloss[${asWsId(filter.missingGlossWs)}]=)`); + } + if (filter.missingExampleWs !== undefined) { + parts.push(`(Senses.ExampleSentences=null|Senses.ExampleSentences.Sentence[${asWsId(filter.missingExampleWs)}]=)`); + } + if (filter.missingPartOfSpeech) { + parts.push('Senses.PartOfSpeechId='); + } if (parts.length === 0) return undefined; return {gridifyFilter: parts.join(',')}; } +function asWsId(value: string): string { + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(value)) { + throw new PluginApiException('invalid-args', `Not a valid writing system code: ${value}`); + } + return value; +} + /** Fills in ids and required collections so a plugin can supply just the interesting fields. */ function normalizeNewEntry(input: Partial): IEntry { const entry: IEntry = { diff --git a/frontend/viewer/src/lib/plugins/plugin-api-types.ts b/frontend/viewer/src/lib/plugins/plugin-api-types.ts index 1c90a9b555..932b0bf9c1 100644 --- a/frontend/viewer/src/lib/plugins/plugin-api-types.ts +++ b/frontend/viewer/src/lib/plugins/plugin-api-types.ts @@ -48,6 +48,7 @@ export interface HostInitMessage { project: {projectName: string; projectCode: string}; theme: 'light' | 'dark'; permissions: PluginPermission[]; + context?: {entryId?: string}; } export interface PluginApiError { @@ -89,6 +90,12 @@ export interface PluginEntryFilter { semanticDomainCode?: string; /** Part of speech id (GUID) */ partOfSpeechId?: string; + /** Writing system code; matches entries with no sense glossed in that writing system. */ + missingGlossWs?: string; + /** Writing system code; matches entries where no sense has an example sentence with text in that writing system. */ + missingExampleWs?: string; + /** Matches entries having a sense without a part of speech (or no senses). */ + missingPartOfSpeech?: boolean; } /** A write a plugin has requested; the user must approve it before it is applied. */ diff --git a/frontend/viewer/src/lib/plugins/plugin-host.ts b/frontend/viewer/src/lib/plugins/plugin-host.ts index 599711387d..092c75515f 100644 --- a/frontend/viewer/src/lib/plugins/plugin-host.ts +++ b/frontend/viewer/src/lib/plugins/plugin-host.ts @@ -13,6 +13,7 @@ export interface PluginHostConfig { projectName: string; projectCode: string; permissions: PluginPermission[]; + entryId?: string; } /** @@ -49,6 +50,7 @@ export class PluginHost { project: {projectName: this.config.projectName, projectCode: this.config.projectCode}, theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light', permissions: this.config.permissions, + ...(this.config.entryId ? {context: {entryId: this.config.entryId}} : {}), }); return; } diff --git a/frontend/viewer/src/lib/plugins/plugin-prompt.ts b/frontend/viewer/src/lib/plugins/plugin-prompt.ts index 9e12ee4a0f..73f1c02553 100644 --- a/frontend/viewer/src/lib/plugins/plugin-prompt.ts +++ b/frontend/viewer/src/lib/plugins/plugin-prompt.ts @@ -56,6 +56,7 @@ All methods return Promises. fwlite.ready: Promise<{apiVersion: 1, project: {projectName, projectCode}, theme: 'light'|'dark', permissions: string[]}> fwlite.project // {projectName, projectCode} — available after ready fwlite.theme // 'light' | 'dark' — the app's current theme; also respect prefers-color-scheme +fwlite.context // {entryId?: string} — launch context; entryId is set only when opened from an entry fwlite.getWritingSystems(): Promise<{vernacular: WritingSystem[], analysis: WritingSystem[]}> // WritingSystem: {wsId: string, name: string, abbreviation: string, font: string, isAudio: boolean, exemplars: string[]} @@ -66,9 +67,17 @@ fwlite.getEntries(query?): Promise // search?: string, // full-text search // limit?: number, // default 100, max 1000 // offset?: number, // for paging -// filter?: {semanticDomainCode?: string, partOfSpeechId?: string}, +// filter?: { +// semanticDomainCode?: string, // entries with a sense in this semantic domain +// partOfSpeechId?: string, // entries with a sense of this part of speech +// missingGlossWs?: string, // entries with no sense glossed in this writing system +// missingExampleWs?: string, // entries where no sense has an example sentence in this writing system +// missingPartOfSpeech?: boolean, // entries having a sense with no part of speech +// }, // all filter conditions compose with AND // sort?: {writingSystem?: string, ascending?: boolean}, // sorts by headword // } +// Filtering runs on the real backend; against the in-browser demo project filters may return +// unfiltered results, so also verify entries client-side if correctness matters. fwlite.countEntries(query?): Promise // query: {search?, filter?} fwlite.getEntry(id): Promise fwlite.getPartsOfSpeech(): Promise<{id: string, name: MultiString}[]> @@ -100,6 +109,12 @@ fwlite.asText(richStringOrString): string // flattens rich text ({spans:[{t fwlite.firstValue(multiString, ['seh','en']): string // first non-empty value, preferring given writing systems \`\`\` +### Launch context + +When the user launches your plugin from a specific entry, \`fwlite.context.entryId\` is set to that +entry's id — start by loading it with \`fwlite.getEntry(fwlite.context.entryId)\`. Otherwise \`context\` +is \`{}\` (no \`entryId\`), so treat the entry-focused flow as optional and fall back to a normal view. + ### Data model \`\`\`ts @@ -143,7 +158,7 @@ ${posList} ## Design guidance - **Responsive**: must work from a 360px phone to a desktop; no horizontal page scrolling. Test your layout mentally at both sizes. -- **Theme**: support light and dark mode. Read \`fwlite.theme\` after ready and respect \`prefers-color-scheme\`; drive colors through CSS custom properties. +- **Theme & palette**: define a small set of CSS custom properties on \`:root\` (\`--bg\`, \`--surface\`, \`--border\`, \`--text\`, \`--text-muted\`, \`--accent\`, \`--radius\`) and drive every color through them. Provide dark values under \`html[data-theme="dark"]\`, then set \`document.documentElement.dataset.theme = fwlite.theme\` after ready (respecting \`prefers-color-scheme\` as a fallback). Never hard-code raw colors on elements. - Use the system font stack; the vernacular writing systems may specify a \`font\` name you can add to a font-family list. - Always render **loading**, **empty** ("no entries yet") and **error** states for async data. - Escape dictionary data before inserting it into the DOM (\`textContent\`, not \`innerHTML\`) — it can contain any characters. @@ -158,7 +173,12 @@ ${posList} My plugin - +
Loading…
diff --git a/frontend/viewer/src/lib/plugins/plugin-sdk.js b/frontend/viewer/src/lib/plugins/plugin-sdk.js index e21ef3791f..3e373b5f07 100644 --- a/frontend/viewer/src/lib/plugins/plugin-sdk.js +++ b/frontend/viewer/src/lib/plugins/plugin-sdk.js @@ -10,6 +10,7 @@ var pending = new Map(); var nextId = 1; var context = null; + var launchContext = {}; var initResolve; var ready = new Promise(function (resolve) { initResolve = resolve; }); @@ -19,6 +20,7 @@ if (!data || data.source !== 'fwlite-plugin-host') return; if (data.kind === 'init') { if (context) return; + launchContext = data.context || {}; context = { apiVersion: data.apiVersion, project: data.project, @@ -59,6 +61,8 @@ get project() { return context && context.project; }, get theme() { return context && context.theme; }, get permissions() { return (context && context.permissions) || []; }, + /** Launch context; `context.entryId` is set when the user opened this plugin from an entry, else absent. */ + get context() { return launchContext; }, getWritingSystems: function () { return call('getWritingSystems', []); }, getEntries: function (query) { return call('getEntries', [query || {}]); }, diff --git a/frontend/viewer/src/locales/en.po b/frontend/viewer/src/locales/en.po index b03ac80103..9dad65eec6 100644 --- a/frontend/viewer/src/locales/en.po +++ b/frontend/viewer/src/locales/en.po @@ -33,6 +33,11 @@ msgstr "(unknown)" msgid "{0}" msgstr "{0}" +#. Default name suggested for a duplicated plugin. {0} is the original plugin's name; "(copy)" is appended as a suffix. +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "{0} (copy)" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -801,6 +806,11 @@ msgstr "Downloading {0}..." msgid "Downloading..." msgstr "Downloading..." +#. Dropdown menu item on a plugin card; creates a copy of that plugin (see "{0} (copy)" for its default name). +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "Duplicate" + #. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" @@ -937,6 +947,17 @@ msgstr "Example sentence" msgid "Example sentences" msgstr "Example sentences" +#. Icon button toggling fullscreen for the running plugin's content (an iframe), not the whole app window. +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "Exit fullscreen" + +#. Dropdown menu item on a plugin card; downloads the plugin as an .html file. +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "Export" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1133,6 +1154,12 @@ msgstr "For any other inquiries, feel free to send us an email." msgid "From active filter" msgstr "From active filter" +#. Icon button toggling fullscreen for the running plugin's content (an iframe), not the whole app window. +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "Fullscreen" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "Gathering project information…" @@ -1222,6 +1249,11 @@ msgstr "I understand that this can't be undone" msgid "Import" msgstr "Import" +#. Button in the plugin editor dialog; loads a plugin's HTML content from a file picked from disk. +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "Import from file" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1443,6 +1475,11 @@ msgstr "Missing: {0}" msgid "Mode" msgstr "Mode" +#. Aria-label/tooltip for the "..." dropdown trigger button on a plugin card, which opens the Export/Duplicate/Delete menu. +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "More" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1717,6 +1754,11 @@ msgstr "Open" msgid "Open Data Directory" msgstr "Open Data Directory" +#. Item in an entry's action menu; {0} is the name of a user-created plugin. Opens that plugin, passing it the current entry. +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "Open in {0}" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" diff --git a/frontend/viewer/src/locales/es.po b/frontend/viewer/src/locales/es.po index 4b97bc2310..e8c55b76c6 100644 --- a/frontend/viewer/src/locales/es.po +++ b/frontend/viewer/src/locales/es.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**Terminología más simple** (p. ej. *Palabra* en lugar de *Forma del l msgid "A new version of FieldWorks Lite is available." msgstr "Una nueva versión de FieldWorks Lite está disponible." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "Añadir nueva palabra" msgid "Add part of" msgstr "Añadir parte de" +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "¿Estás seguro de que quieres borrar {0}?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "Auto" msgid "Auto syncing" msgstr "Sincronización automática" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "Visite" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "Copiado al portapapeles" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "Descargando {0}..." msgid "Downloading..." msgstr "Descargando..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "Error al obtener el estado de sincronización" msgid "Error getting sync status." msgstr "Error al obtener el estado de sincronización." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "Ejemplo de frase" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "Para cualquier otra consulta, no dude en enviarnos un correo electrónic msgid "From active filter" msgstr "De filtro activo" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "Obtener soporte" msgid "Gloss" msgstr "Glosa" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "Entiendo que esto no se puede deshacer" msgid "Import" msgstr "Importar" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "Instalar actualización" msgid "Installing Update..." msgstr "Instalando actualización..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "Falta: {0}" msgid "Mode" msgstr "Modo" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "Sin audio" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "Ningún archivo para subir" msgid "No history found" msgstr "No se han encontrado antecedentes" +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "No se han encontrado artículos" msgid "No new data" msgstr "No hay nuevos datos" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "No hay tema, no se puede crear un nuevo {0}" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "Abrir" msgid "Open Data Directory" msgstr "Directorio de datos abiertos" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "Parte de" msgid "Part of speech" msgstr "Parte de la oración" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "Pinned" msgid "Platform" msgstr "Plataforma" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "Actualizar proyectos" msgid "Release notes" msgstr "Notas de lanzamiento" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "Sustituir audio" msgid "Report a technical problem" msgstr "Reportar un problema técnico" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "Revisión" msgid "rose" msgstr "rosa" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "Guardar como" msgid "Save audio" msgstr "Guardar audio" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "Tamaño:" msgid "Skip" msgstr "Saltar" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "Campo específico" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "El número de commits de FieldWorks Classic no necesariamente coincidir msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "El número de commits de FieldWorks Lite no necesariamente coincidirá con el número de cambios mostrados en el mensaje de resultado de sincronización." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "Este {0} ha sido eliminado" msgid "This date # and this emoji # are snippets" msgstr "Esta fecha # y este emoji # son fragmentos" +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "Este proyecto está ahora abierto en FieldWorks. Para continuar trabajando en FieldWorks Lite, cierre el proyecto en FieldWorks y haga clic en Vuelva a abrir." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/locales/fr.po b/frontend/viewer/src/locales/fr.po index 423ffb3d78..fb365efcc5 100644 --- a/frontend/viewer/src/locales/fr.po +++ b/frontend/viewer/src/locales/fr.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**terminologie plus simple** (par exemple *Mot* au lieu de *forme de lex msgid "A new version of FieldWorks Lite is available." msgstr "Une nouvelle version de FieldWorks Lite est disponible." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "Ajouter un nouveau mot" msgid "Add part of" msgstr "Ajouter une partie de" +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "Êtes-vous sûr de vouloir supprimer {0}?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "Auto" msgid "Auto syncing" msgstr "Synchronisation automatique" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "Parcourir" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "Copié dans le presse-papiers" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "Téléchargement en cours {0}..." msgid "Downloading..." msgstr "Téléchargement en cours..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "Erreur dans l'obtention de l'état de la synchronisation" msgid "Error getting sync status." msgstr "Erreur dans l'obtention de l'état de la synchronisation." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "Phrase exemplaire" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "Pour toute autre question, n'hésitez pas à nous envoyer un courriel." msgid "From active filter" msgstr "Depuis le filtre actif" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "Obtenir de l'aide" msgid "Gloss" msgstr "Glose" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "Je comprends que cela ne peut pas être annulé" msgid "Import" msgstr "Importer" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "Installer la mise à jour" msgid "Installing Update..." msgstr "Installation de la mise à jour..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "Manquant : {0}" msgid "Mode" msgstr "Mode" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "Aucun contenu audio" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "Aucun fichier à télécharger" msgid "No history found" msgstr "Aucun antécédent n'a été trouvé" +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "Aucun élément trouvé" msgid "No new data" msgstr "Aucune nouvelle donnée" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "Aucun sujet, impossible de créer un nouveau {0}" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "Ouvert" msgid "Open Data Directory" msgstr "Répertoire des données ouvertes" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "Une partie de" msgid "Part of speech" msgstr "Partie du discours" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "Épinglé" msgid "Platform" msgstr "Plateforme" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "Actualiser la liste de projets" msgid "Release notes" msgstr "Notes de version" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "Remplacer l'audio" msgid "Report a technical problem" msgstr "Signaler un problème technique" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "Évaluer" msgid "rose" msgstr "rose" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "Enregistrer sous" msgid "Save audio" msgstr "Sauvegarder l'audio" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "Taille :" msgid "Skip" msgstr "Ignorer" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "Champ spécifique" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "Le nombre de commits FieldWorks Classic ne correspond pas nécessairemen msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "Le nombre de commits FieldWorks Lite ne correspond pas nécessairement au nombre de modifications affichées dans le message de résultat de synchronisation." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "Cet {0} a été supprimé" msgid "This date # and this emoji # are snippets" msgstr "Cette date # et cet emoji # sont des extraits." +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "Ce projet est maintenant ouvert dans FieldWorks. Pour continuer à travailler dans FieldWorks Lite, fermez le projet dans FieldWorks et cliquez sur Rouvrir." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/locales/id.po b/frontend/viewer/src/locales/id.po index 4ba82da181..7e7be3f74e 100644 --- a/frontend/viewer/src/locales/id.po +++ b/frontend/viewer/src/locales/id.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**Terminologi yang lebih sederhana** (mis. *Kata* alih-alih *Bentuk leks msgid "A new version of FieldWorks Lite is available." msgstr "Versi baru FieldWorks Lite telah tersedia." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "Tambahkan kata baru" msgid "Add part of" msgstr "Tambahkan bagian dari" +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "Apakah Anda yakin ingin menghapus {0}?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "Otomatis" msgid "Auto syncing" msgstr "Sinkronisasi otomatis" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "Jelajahi" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "Disalin ke papan klip" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "Mengunduh {0}..." msgid "Downloading..." msgstr "Mengunduh..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "Kesalahan mendapatkan status sinkronisasi" msgid "Error getting sync status." msgstr "Kesalahan mendapatkan status sinkronisasi." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "Contoh kalimat" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "Untuk pertanyaan lainnya, silakan kirimkan email kepada kami." msgid "From active filter" msgstr "Dari filter aktif" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "Dapatkan dukungan" msgid "Gloss" msgstr "Arti Singkat" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "Saya memahami bahwa hal ini tidak dapat dibatalkan" msgid "Import" msgstr "Impor" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "Instal Pembaruan" msgid "Installing Update..." msgstr "Menginstal Pembaruan..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "Hilang: {0}" msgid "Mode" msgstr "Mode" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "Tidak ada audio" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "Tidak ada file untuk diunggah" msgid "No history found" msgstr "Tidak ditemukan riwayat" +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "Tidak ada barang yang ditemukan" msgid "No new data" msgstr "Tidak ada data baru" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "Tidak ada subjek, tidak dapat membuat {0}baru" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "Buka" msgid "Open Data Directory" msgstr "Direktori Data Terbuka" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "Bagian dari" msgid "Part of speech" msgstr "Bagian dari pidato" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "Disematkan" msgid "Platform" msgstr "Platform" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "Menyegarkan Proyek" msgid "Release notes" msgstr "Catatan rilis" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "Mengganti audio" msgid "Report a technical problem" msgstr "Melaporkan masalah teknis" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "Ulasan" msgid "rose" msgstr "naik" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "Simpan Sebagai" msgid "Save audio" msgstr "Menyimpan audio" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "Ukuran:" msgid "Skip" msgstr "Lewati" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "Bidang tertentu" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "Jumlah komit FieldWorks Classic tidak akan selalu sama dengan jumlah per msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "Jumlah komit FieldWorks Lite tidak akan selalu sama dengan jumlah perubahan yang ditampilkan dalam pesan hasil sinkronisasi." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "{0} ini telah dihapus" msgid "This date # and this emoji # are snippets" msgstr "Tanggal ini # dan emoji ini # adalah cuplikan" +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "Proyek ini sekarang terbuka di FieldWorks. Untuk melanjutkan bekerja di FieldWorks Lite, tutup proyek di FieldWorks dan klik Buka Kembali." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/locales/ko.po b/frontend/viewer/src/locales/ko.po index d2c26d30a1..bed554f5b5 100644 --- a/frontend/viewer/src/locales/ko.po +++ b/frontend/viewer/src/locales/ko.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**간단한 용어 사용** (예: *어휘 형태* 대신 *단어*, *의 msgid "A new version of FieldWorks Lite is available." msgstr "새 버전의 FieldWorks Lite를 사용할 수 있습니다." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "새 단어 추가" msgid "Add part of" msgstr "의 일부를 추가합니다." +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "{0}을 삭제하시겠습니까?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "자동" msgid "Auto syncing" msgstr "자동 동기화" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "찾아보기" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "클립보드에 복사" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "다운로드 {0}..." msgid "Downloading..." msgstr "다운로드 중..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "동기화 상태 가져오기 오류" msgid "Error getting sync status." msgstr "동기화 상태를 가져오는 중 오류가 발생했습니다." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "문장 예시" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "기타 문의 사항이 있으시면 언제든지 이메일을 보내주 msgid "From active filter" msgstr "활성 필터에서" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "지원 받기" msgid "Gloss" msgstr "광택" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "이 작업은 되돌릴 수 없다는 것을 이해합니다." msgid "Import" msgstr "가져오기" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "업데이트 설치" msgid "Installing Update..." msgstr "업데이트 설치 중..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "누락되었습니다: {0}" msgid "Mode" msgstr "모드" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "오디오 없음" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "업로드할 파일이 없습니다." msgid "No history found" msgstr "기록을 찾을 수 없습니다." +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "항목을 찾을 수 없습니다." msgid "No new data" msgstr "새 데이터 없음" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "제목이 없습니다, 새로 만들 수 없습니다 {0}" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "열기" msgid "Open Data Directory" msgstr "오픈 데이터 디렉터리" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "의 일부" msgid "Part of speech" msgstr "품사" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "고정됨" msgid "Platform" msgstr "플랫폼" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "프로젝트 새로 고침" msgid "Release notes" msgstr "릴리스 노트" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "오디오 교체" msgid "Report a technical problem" msgstr "기술 문제 신고하기" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "검토" msgid "rose" msgstr "rose" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "다른 이름으로 저장" msgid "Save audio" msgstr "오디오 저장" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "크기:" msgid "Skip" msgstr "건너뛰기" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "특정 필드" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "FieldWorks Classic 커밋 수가 동기화 결과 메시지에 표시되 msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "FieldWorks Lite 커밋 수가 동기화 결과 메시지에 표시되는 변경 사항 수와 반드시 일치하지는 않습니다." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "이 {0} 삭제됨" msgid "This date # and this emoji # are snippets" msgstr "이 날짜 #와 이 이모티콘 #은 스니펫입니다." +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "이제 이 프로젝트가 FieldWorks에서 열렸습니다. FieldWorks Lite에서 계속 작업하려면 FieldWorks에서 프로젝트를 닫고 다시 열기를 클릭합니다." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/locales/ms.po b/frontend/viewer/src/locales/ms.po index a9194c6ac3..675cad7a49 100644 --- a/frontend/viewer/src/locales/ms.po +++ b/frontend/viewer/src/locales/ms.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**Terminologi lebih mudah** (cth. *Perkataan* bukan *bentuk leksem*, *Ma msgid "A new version of FieldWorks Lite is available." msgstr "Versi baharu FieldWorks Lite tersedia." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "Tambah perkataan baharu" msgid "Add part of" msgstr "Tambah sebahagian daripada" +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "Adakah anda pasti mahu memadam {0}?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "Auto" msgid "Auto syncing" msgstr "Segerakkan automatik" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "Terokai" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "Disalin ke papan keratan" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "Memuat turun {0}..." msgid "Downloading..." msgstr "Memuat turun..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "Ralat mendapatkan status penyegerakan" msgid "Error getting sync status." msgstr "Ralat mendapatkan status penyegerakan." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "Ayat contoh" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "Untuk sebarang pertanyaan lain, jangan ragu untuk menghantar kami e-mel. msgid "From active filter" msgstr "Dari penapis aktif" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "Dapatkan sokongan" msgid "Gloss" msgstr "Glos" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "Saya faham bahawa ini tidak boleh dibuat asal" msgid "Import" msgstr "Masukkan" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "Pasang Kemas Kini" msgid "Installing Update..." msgstr "Memasang Kemas Kini..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "Hilang: {0}" msgid "Mode" msgstr "Mod" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "Tiada audio" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "Tiada fail untuk dimuat naik" msgid "No history found" msgstr "Tiada sejarah ditemui" +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "Tiada item ditemui" msgid "No new data" msgstr "Tiada data baru" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "Tiada subjek, tidak dapat membuat {0} baru" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "Buka" msgid "Open Data Directory" msgstr "Buka Direktori Data" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "Sebahagian daripada" msgid "Part of speech" msgstr "Jenis Perkataan" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "Disemat" msgid "Platform" msgstr "Platform" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "Segarkan Semula Projek" msgid "Release notes" msgstr "Nota keluaran" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "Ganti audio" msgid "Report a technical problem" msgstr "Laporkan masalah teknikal" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "Semak" msgid "rose" msgstr "merah jambu" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "Simpan Sebagai" msgid "Save audio" msgstr "Simpan audio" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "Saiz:" msgid "Skip" msgstr "Langkau" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "Medan tertentu" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "Bilangan komit FieldWorks Classic tidak semestinya sepadan dengan bilang msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "Bilangan komit FieldWorks Lite tidak semestinya sepadan dengan bilangan perubahan yang ditunjukkan dalam mesej hasil segerak." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "Item {0} telah dipadam" msgid "This date # and this emoji # are snippets" msgstr "Tarikh ini # dan emoji ini # adalah coretan" +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "Projek ini kini dibuka dalam FieldWorks. Untuk terus bekerja dalam FieldWorks Lite, tutup projek dalam FieldWorks dan klik Buka Semula." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/locales/sw.po b/frontend/viewer/src/locales/sw.po index 0fc37ebf2e..be46bc180b 100644 --- a/frontend/viewer/src/locales/sw.po +++ b/frontend/viewer/src/locales/sw.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**Istilahi rahisi** (kwa mfano *Neno* badala ya *Lexeme form*, *Maana* b msgid "A new version of FieldWorks Lite is available." msgstr "Toleo jipya la FieldWorks Lite linapatikana." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "Ongeza neno jipya" msgid "Add part of" msgstr "Ongeza sehemu ya" +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "Je, una uhakika kuwa unataka kufuta {0}?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "Otomatiki" msgid "Auto syncing" msgstr "Kuoanisha otomatiki" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "Angalia" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "Imenakiliwa kwenye ubao wa kunakili" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "Inapakua {0}..." msgid "Downloading..." msgstr "Inapakua..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "Hitilafu katika kupata hali ya kuoanisha" msgid "Error getting sync status." msgstr "Hitilafu katika kupata hali ya kuoanisha." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "Sentensi ya mfano" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "Kwa kuuliza kingine chochote, tafadhali tuambii imeli." msgid "From active filter" msgstr "Kutoka kwa kichuja kilichoanzishwa" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "Pata msaada" msgid "Gloss" msgstr "Glosi" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "Ninaelewa kuwa hii haiwezi kurudishwa nyuma" msgid "Import" msgstr "Ingiza" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "Sakinisha Sasisho" msgid "Installing Update..." msgstr "Inasakinisha Sasisho..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "Yenye kushindwa: {0}" msgid "Mode" msgstr "Hali" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "Hakuna sauti" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "Hakuna faili la kuweka" msgid "No history found" msgstr "Hakuna historia iliyopatikana" +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "Hakuna kitu kilichopatikana" msgid "No new data" msgstr "Hakuna data mpya" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "Hakuna kigezo, haiwezi kutengeneza {0} mpya" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "Fungua" msgid "Open Data Directory" msgstr "Fungua Faharasa ya Data" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "Sehemu ya" msgid "Part of speech" msgstr "Sehemu za mazungumzo" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "Imebandikwa" msgid "Platform" msgstr "Jukwaa" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "Onyesha Upya Miradi" msgid "Release notes" msgstr "Maelezo ya toleo" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "Badili sauti" msgid "Report a technical problem" msgstr "Ripoti tatizo la kiufundi" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "Tathmini" msgid "rose" msgstr "waridi" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "Hifadhi Kama" msgid "Save audio" msgstr "Hifadhi sauti" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "Ukubwa:" msgid "Skip" msgstr "Ruka" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "Sehemu mahsusi" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "Idadi ya kumbukas za FieldWorks Classic haitabindanishi lazima na idadi msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "Idadi ya kumbukas za FieldWorks Lite haitabindanishi lazima na idadi ya mabadiliko yaliyoonyeshwa katika ujumbe wa matokeo ya kuoanisha." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "Hili {0} limefutwa" msgid "This date # and this emoji # are snippets" msgstr "Tarehe hii # na emoji hii # ni vigezo" +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "Mradi huu umefunguliwa kwenye FieldWorks. Ili kuendelea kufanya kazi kwenye FieldWorks Lite, funga mradi katika FieldWorks na bofya Fungua Upya." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/locales/vi.po b/frontend/viewer/src/locales/vi.po index 183b3c1096..cbe50ccfb4 100644 --- a/frontend/viewer/src/locales/vi.po +++ b/frontend/viewer/src/locales/vi.po @@ -38,6 +38,10 @@ msgstr "" msgid "{0}" msgstr "{0}" +#: src/lib/plugins/PluginsView.svelte +msgid "{0} (copy)" +msgstr "" + #. Suffix for field labels #: src/lib/components/editor/field/field-title.svelte msgid "{0} (FieldWorks Lite)" @@ -111,6 +115,7 @@ msgstr "**Thuật ngữ đơn giản hơn** (ví dụ *Từ* thay vì *Hình th msgid "A new version of FieldWorks Lite is available." msgstr "Đã có phiên bản mới của FieldWorks Lite." +#. Dialog subtitle in the plugin editor (add/edit a plugin). "Get AI prompt" names a button elsewhere in the Plugins page. #: src/lib/plugins/PluginEditorDialog.svelte msgid "A plugin is a single HTML file. Ask an AI to write one for you (see “Get AI prompt”), start from an example, or write it yourself." msgstr "" @@ -190,6 +195,7 @@ msgstr "Thêm từ mới" msgid "Add part of" msgstr "Thêm phần của" +#. Submit button in the "New plugin" dialog, saves a newly-created plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Add plugin" msgstr "" @@ -303,6 +309,7 @@ msgstr "" msgid "Are you sure you want to delete {0}?" msgstr "Bạn có chắc chắn muốn xóa {0}?" +#. Empty-state hint on the Plugins page when no plugins are installed yet. "Get AI prompt" and "New plugin" are the two buttons above this text. #: src/lib/plugins/PluginsView.svelte msgid "Ask an AI to build one for you — click “Get AI prompt”, describe your idea, and paste the result into “New plugin”. Or start from a built-in example." msgstr "" @@ -342,6 +349,7 @@ msgstr "Tự động" msgid "Auto syncing" msgstr "Đang đồng bộ tự động" +#. Button to leave a running plugin and return to the Plugins list page. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginRunView.svelte msgid "Back to plugins" @@ -378,6 +386,7 @@ msgstr "Duyệt" msgid "Browse view failed" msgstr "" +#. List item in the plugin permission/consent screen, shown before first running a plugin that requests internet access. #: src/lib/plugins/PluginRunView.svelte msgid "Can access the internet (and could send dictionary data there)" msgstr "" @@ -544,10 +553,12 @@ msgstr "Đã sao chép vào khay nhớ tạm" msgid "Copy prompt" msgstr "" +#. Step 3 of a numbered list in the "Create a plugin with AI" dialog. "New plugin" names the button used to add the result. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the HTML file the AI produces, then add it here via “New plugin”." msgstr "" +#. Step 1 of a numbered list in the "Create a plugin with AI" dialog, referring to the AI prompt text shown below it. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Copy the prompt below. It already contains everything the AI needs to know about plugins and this project." msgstr "" @@ -573,6 +584,7 @@ msgstr "" msgid "Create a new FieldWorks Lite project" msgstr "" +#. Dialog title. Walks the user through generating a plugin (a custom HTML mini-app) using an external AI assistant. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Create a plugin with AI" msgstr "" @@ -798,6 +810,11 @@ msgstr "Đang tải xuống {0}..." msgid "Downloading..." msgstr "Đang tải xuống..." +#: src/lib/plugins/PluginsView.svelte +msgid "Duplicate" +msgstr "" + +#. Placeholder text in the plugin name input field, an example plugin name. #: src/lib/plugins/PluginEditorDialog.svelte msgid "e.g. Dictionary stats" msgstr "" @@ -909,6 +926,7 @@ msgstr "Lỗi khi lấy trạng thái đồng bộ" msgid "Error getting sync status." msgstr "Lỗi khi lấy trạng thái đồng bộ." +#. List item in the plugin permission/consent screen, reassuring the user before they run a plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Every change to your dictionary needs your approval" msgstr "" @@ -932,6 +950,15 @@ msgstr "Câu ví dụ" msgid "Example sentences" msgstr "" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Exit fullscreen" +msgstr "" + +#: src/lib/plugins/PluginsView.svelte +msgid "Export" +msgstr "" + #. Error message when copy fails #: src/lib/components/ui/button/copy-button.svelte msgid "Failed to copy to clipboard" @@ -1128,6 +1155,11 @@ msgstr "Với các yêu cầu khác, vui lòng gửi email cho chúng tôi." msgid "From active filter" msgstr "Từ bộ lọc đang hoạt động" +#: src/lib/plugins/PluginRunView.svelte +#: src/lib/plugins/PluginRunView.svelte +msgid "Fullscreen" +msgstr "" + #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Gathering project information…" msgstr "" @@ -1150,6 +1182,7 @@ msgstr "Nhận hỗ trợ" msgid "Gloss" msgstr "Ghi chú" +#. Button in the plugin permission/consent screen that declines to run the plugin and returns to the Plugins list. #: src/lib/plugins/PluginRunView.svelte msgid "Go back" msgstr "" @@ -1216,6 +1249,10 @@ msgstr "Tôi hiểu rằng điều này không thể hoàn tác" msgid "Import" msgstr "Nhập" +#: src/lib/plugins/PluginEditorDialog.svelte +msgid "Import from file" +msgstr "" + #. Future relative date format. {0} = formatted duration string (e.g., "3 hours", "2 days"). Paired with "{0} ago" for past dates. #: src/lib/components/ui/format/format-relative-date-fn.svelte.ts msgid "in {0}" @@ -1241,6 +1278,7 @@ msgstr "Cài đặt bản cập nhật" msgid "Installing Update..." msgstr "Đang cài đặt bản cập nhật..." +#. Badge label shown on a plugin that requests internet access. Short noun, not a verb/instruction. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Internet" @@ -1436,6 +1474,10 @@ msgstr "Thiếu: {0}" msgid "Mode" msgstr "Chế độ" +#: src/lib/plugins/PluginsView.svelte +msgid "More" +msgstr "" + #. Drag-handle or button tooltip to reorder an item in a list (e.g., senses or examples within an entry). #: src/lib/entry-editor/ItemListItem.svelte msgid "Move" @@ -1546,6 +1588,7 @@ msgstr "Không có âm thanh" msgid "No authors" msgstr "" +#. Dialog subtitle in "Create a plugin with AI". "Plugin" here means a small custom HTML mini-app for the dictionary. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "No coding needed — an AI assistant can write the plugin for you." msgstr "" @@ -1579,6 +1622,7 @@ msgstr "Không có tệp để tải lên" msgid "No history found" msgstr "Không tìm thấy lịch sử" +#. Badge shown when a plugin does NOT request internet access (contrast with the "Requests internet access" / "Internet" badges). #: src/lib/plugins/PluginEditorDialog.svelte #: src/lib/plugins/PluginRunView.svelte msgid "No internet access" @@ -1594,6 +1638,7 @@ msgstr "Không tìm thấy mục nào" msgid "No new data" msgstr "Không có dữ liệu mới" +#. Empty-state heading on the Plugins page when the project has no plugins installed. #: src/lib/plugins/PluginsView.svelte msgid "No plugins yet" msgstr "" @@ -1617,6 +1662,7 @@ msgstr "Không có chủ đề, không thể tạo mới {0}" msgid "No vernacular writing systems configured." msgstr "" +#. Shown in the plugin write-confirmation dialog when the proposed change has no human-readable summary lines to display. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "No visible changes" msgstr "" @@ -1706,6 +1752,10 @@ msgstr "Mở" msgid "Open Data Directory" msgstr "Mở Thư mục Dữ liệu" +#: src/project/browse/EntryMenu.svelte +msgid "Open in {0}" +msgstr "" + #. Action button on the offline-login warning toast; opens the server's site in a browser so the user can check the connection. #: src/lib/auth/LoginButton.svelte msgid "Open in browser" @@ -1767,10 +1817,12 @@ msgstr "Một phần của" msgid "Part of speech" msgstr "Loại từ" +#. Step 2 of a numbered list in the "Create a plugin with AI" dialog. "Claude" is a product name — do not translate; "ChatGPT" is also a product name. #: src/lib/plugins/PluginAiPromptDialog.svelte msgid "Paste it into an AI assistant (e.g. Claude or ChatGPT) and replace the last paragraph with a description of the plugin you want." msgstr "" +#. Placeholder text in the plugin HTML textarea of the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Paste the plugin HTML here" msgstr "" @@ -1799,11 +1851,13 @@ msgstr "Đã ghim" msgid "Platform" msgstr "Nền tảng" +#. Generic fallback label for a plugin (a small custom HTML mini-app) when its name is unavailable, and as the noun in delete-confirmation prompts. #: src/lib/plugins/PluginRunView.svelte #: src/lib/plugins/PluginsView.svelte msgid "Plugin" msgstr "" +#. Field label above the textarea where the plugin's HTML source code is pasted, in the plugin editor dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugin HTML" msgstr "" @@ -1816,19 +1870,23 @@ msgstr "" msgid "Plugin wants to change an entry" msgstr "" +#. Sidebar navigation item leading to the list of installed plugins (small custom HTML mini-apps) for the current project. #: src/lib/plugins/PluginsView.svelte #: src/project/ProjectSidebar.svelte msgid "Plugins" msgstr "" +#. Warning banner in the plugin editor dialog, shown when adding or editing a plugin. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Plugins are code and run for everyone on this project. Only add plugins you or your team created, or that come from someone you trust." msgstr "" +#. Body text in the permission/consent screen shown the first time a plugin runs on this device, or after its content changed. #: src/lib/plugins/PluginRunView.svelte msgid "Plugins are code written by people on your team (often with AI help). This one hasn't run on this device yet, or it changed since it last ran." msgstr "" +#. Warning banner at the top of the Plugins list page. #: src/lib/plugins/PluginsView.svelte msgid "Plugins are code. They run in a protected sandbox and can only change dictionary data with your approval, but you should still only use plugins from people you trust." msgstr "" @@ -1908,6 +1966,7 @@ msgstr "Làm mới Dự án" msgid "Release notes" msgstr "Ghi chú phát hành" +#. Icon button in the plugin run view's toolbar; re-executes the currently running plugin from scratch. #: src/lib/plugins/PluginRunView.svelte msgid "Reload plugin" msgstr "" @@ -1937,6 +1996,7 @@ msgstr "Thay thế âm thanh" msgid "Report a technical problem" msgstr "Báo lỗi kỹ thuật" +#. Badge shown on a plugin in the editor dialog that requests internet access (contrast with "No internet access"). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Requests internet access" msgstr "" @@ -1956,18 +2016,22 @@ msgstr "Xem lại" msgid "rose" msgstr "hoa hồng" +#. Button on a plugin card in the Plugins list; opens and executes that plugin. #: src/lib/plugins/PluginsView.svelte msgid "Run" msgstr "" +#. Button in the permission/consent screen that grants approval and executes the plugin for the first time. #: src/lib/plugins/PluginRunView.svelte msgid "Run plugin" msgstr "" +#. Heading of the permission/consent screen shown before a plugin runs for the first time (or after it changed). #: src/lib/plugins/PluginRunView.svelte msgid "Run this plugin?" msgstr "" +#. List item in the plugin permission/consent screen, describing the iframe sandbox the plugin executes in. #: src/lib/plugins/PluginRunView.svelte msgid "Runs in a sandbox, separate from the app" msgstr "" @@ -1986,6 +2050,7 @@ msgstr "Lưu thành" msgid "Save audio" msgstr "Lưu âm thanh" +#. Submit button in the plugin editor dialog when editing an existing plugin (contrast with "Add plugin" for a new one). #: src/lib/plugins/PluginEditorDialog.svelte msgid "Save changes" msgstr "" @@ -2127,6 +2192,7 @@ msgstr "Kích thước:" msgid "Skip" msgstr "Bỏ qua" +#. Subtitle under the "Plugins" page heading, explaining what plugins are. #: src/lib/plugins/PluginsView.svelte msgid "Small custom apps that work with this project's dictionary — shared with the whole team." msgstr "" @@ -2144,6 +2210,7 @@ msgstr "Trường cụ thể" msgid "Start a new thread" msgstr "" +#. Label preceding a row of buttons, each naming a built-in example plugin, in the "New plugin" dialog. #: src/lib/plugins/PluginEditorDialog.svelte msgid "Start from an example:" msgstr "" @@ -2291,6 +2358,7 @@ msgstr "Số commit của FieldWorks Classic không nhất thiết khớp với msgid "The number of FieldWorks Lite commits will not necessarily match the number of changes shown in the sync result message." msgstr "Số commit của FieldWorks Lite không nhất thiết khớp với số thay đổi hiển thị trong thông báo kết quả đồng bộ." +#. Dialog description; {0} is the plugin's display name. Followed by a list of the specific changes it wants to make. #: src/lib/plugins/PluginWriteConfirmDialog.svelte msgid "The plugin “{0}” is asking to make this change to your dictionary:" msgstr "" @@ -2321,6 +2389,7 @@ msgstr "Đường dẫn này {0} đã bị xóa." msgid "This date # and this emoji # are snippets" msgstr "Ngày này # và biểu tượng cảm xúc này # là đoạn trích" +#. Shown in the plugin run view if the plugin was deleted (e.g. by another user) after this page was opened or linked to. #: src/lib/plugins/PluginRunView.svelte msgid "This plugin no longer exists." msgstr "" @@ -2330,6 +2399,7 @@ msgstr "" msgid "This project is now open in FieldWorks. To continue working in FieldWorks Lite, close the project in FieldWorks and click Reopen." msgstr "Dự án này hiện đã mở trong FieldWorks. Để tiếp tục làm việc trong FieldWorks Lite, đóng dự án trong FieldWorks và nhấp Mở lại." +#. Warning detail in the delete-plugin confirmation dialog; plugins are shared project-wide, not per-user. #: src/lib/plugins/PluginsView.svelte msgid "This removes the plugin for everyone on this project." msgstr "" diff --git a/frontend/viewer/src/project/browse/EntryMenu.svelte b/frontend/viewer/src/project/browse/EntryMenu.svelte index 247d8e395a..eb552281d3 100644 --- a/frontend/viewer/src/project/browse/EntryMenu.svelte +++ b/frontend/viewer/src/project/browse/EntryMenu.svelte @@ -15,6 +15,8 @@ import {useAppLauncherService} from '$lib/services/app-launcher-service'; import {IsMobile} from '$lib/hooks/is-mobile.svelte'; import OpenInFieldWorksButton from '$lib/components/OpenInFieldWorksButton.svelte'; + import {navigate, useRouter} from 'svelte-routing'; + import {usePluginService} from '$project/data/plugin-service.svelte'; const multiWindowService = useMultiWindowService(); const dialogsService = useDialogsService(); @@ -42,6 +44,17 @@ const features = useFeatures(); let showHistoryView = $state(false); const appLauncher = useAppLauncherService(); + + const {base} = useRouter(); + const pluginService = usePluginService(); + const plugins = $derived(features.plugins ? pluginService.current : []); + // Prime the lazy plugin resource so the submenu isn't empty the first time the menu opens. + $effect(() => { + if (features.plugins) void pluginService.current; + }); + function openInPlugin(pluginId: string) { + navigate(`${$base.uri}/plugins/${pluginId}?entryId=${entry.id}`); + } {#if features.history} @@ -72,5 +85,10 @@ {/snippet} {/if} + {#each plugins as plugin (plugin.id)} + openInPlugin(plugin.id)}> + {$t`Open in ${plugin.name}`} + + {/each} diff --git a/frontend/viewer/src/project/demo/in-memory-demo-api.ts b/frontend/viewer/src/project/demo/in-memory-demo-api.ts index d9806207ba..bcedaa4cd2 100644 --- a/frontend/viewer/src/project/demo/in-memory-demo-api.ts +++ b/frontend/viewer/src/project/demo/in-memory-demo-api.ts @@ -605,19 +605,32 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable { return Promise.resolve(); } - private _plugins: IPlugin[] = [{ - id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', - name: examplePlugins[0]?.name ?? 'Dictionary stats', - html: examplePlugins[0]?.html ?? '

Example plugin

', - }]; + private _plugins: IPlugin[] = []; + private _seedPlugins?: Promise; + + private seedPlugins(): Promise { + return this._seedPlugins ??= (async () => { + const seeds: {id: string; key: string}[] = [ + {id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', key: 'dictionary-stats'}, + {id: 'b3d4e5f6-a7b8-4c9d-8e1f-2a3b4c5d6e7f', key: 'lexicon-galaxy'}, + ]; + for (const seed of seeds) { + const example = examplePlugins.find(p => p.key === seed.key); + if (!example) continue; + this._plugins.push({id: seed.id, name: example.name, html: await example.loadHtml()}); + } + })(); + } - getPlugins(): Promise { - return Promise.resolve(this._plugins.map(plugin => ({...plugin}))); + async getPlugins(): Promise { + await this.seedPlugins(); + return this._plugins.map(plugin => ({...plugin})); } - getPlugin(id: string): Promise { + async getPlugin(id: string): Promise { + await this.seedPlugins(); const found = this._plugins.find(plugin => plugin.id === id) ?? null; - return Promise.resolve(found ? {...found} : null); + return found ? {...found} : null; } createPlugin(plugin: IPlugin): Promise { diff --git a/frontend/viewer/tests/plugins.test.ts b/frontend/viewer/tests/plugins.test.ts index 0b2e9de830..925226fe75 100644 --- a/frontend/viewer/tests/plugins.test.ts +++ b/frontend/viewer/tests/plugins.test.ts @@ -1,4 +1,5 @@ import {expect, test, type Page} from '@playwright/test'; +import {examplePlugins} from '../src/lib/plugins/examples'; import {waitForProjectViewReady} from './test-utils'; async function gotoPlugins(page: Page) { @@ -93,3 +94,34 @@ test('plugin writes require user approval and apply after it', async ({page}) => await page.getByRole('button', {name: 'Add entry'}).click(); await expect(frame.locator('#status')).toContainText('created:'); }); + +test('every bundled example plugin runs against the demo project without erroring', async ({page}) => { + expect(examplePlugins.length).toBeGreaterThanOrEqual(8); + await gotoPlugins(page); + + for (const example of examplePlugins.map(e => e.name)) { + const pluginName = `E2E ${example}`; + + await page.getByRole('button', {name: 'New plugin'}).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Name').fill(pluginName); + await dialog.getByRole('button', {name: example, exact: true}).click(); + const addButton = dialog.getByRole('button', {name: 'Add plugin'}); + await expect(addButton).toBeEnabled(); + await addButton.click(); + + const card = page.locator('[data-slot="card"]', {hasText: pluginName}); + await expect(card).toBeVisible(); + await card.getByRole('button', {name: 'Run'}).click(); + await page.getByRole('button', {name: 'Run plugin'}).click(); + + const frame = page.frameLocator(`iframe[title="${pluginName}"]`); + // Loading clearing proves the plugin finished talking to the project over the bridge. + await expect(frame.locator('#loading')).toBeHidden({timeout: 20000}); + await expect(frame.locator('#error')).toBeHidden(); + await expect(frame.getByRole('heading', {name: example}).first()).toBeVisible(); + + await page.getByRole('button', {name: 'Back to plugins'}).click(); + await expect(page.getByRole('heading', {name: 'Plugins'})).toBeVisible(); + } +}); From f3bc6c5ee084e166984deccce96f0b85578a0b06 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 8 Jul 2026 17:20:17 +0200 Subject: [PATCH 03/16] Expand plugin examples and add plugin description field Add plugin Description (CRDT migration + validator + API surface), broaden the plugin API/SDK, and add many new example plugins. Co-Authored-By: Claude Opus 4.8 --- backend/FwLite/FwLiteWeb/FwLiteWebServer.cs | 10 +- .../LcmCrdt.Tests/MiniLcmTests/PluginTests.cs | 15 + .../LcmCrdt/Changes/CreatePluginChange.cs | 3 + .../LcmCrdt/Changes/EditPluginChange.cs | 3 + ...708143535_AddPluginDescription.Designer.cs | 1028 ++++++++++++++++ .../20260708143535_AddPluginDescription.cs | 28 + .../LcmCrdtDbContextModelSnapshot.cs | 3 + backend/FwLite/MiniLcm/Models/Plugin.cs | 1 + .../MiniLcm/Validators/PluginValidator.cs | 2 + .../generated-types/MiniLcm/Models/IPlugin.ts | 1 + .../lib/entry-editor/EditEntryDialog.svelte | 28 +- .../lib/plugins/PluginAiPromptDialog.svelte | 31 +- .../src/lib/plugins/PluginEditorDialog.svelte | 375 ++++-- .../src/lib/plugins/PluginRunView.svelte | 45 +- .../plugins/PluginWriteConfirmDialog.svelte | 4 + .../viewer/src/lib/plugins/PluginsView.svelte | 13 +- .../plugins/examples/audio-dictionary.html | 895 ++++++++++++++ .../src/lib/plugins/examples/browse-grid.html | 762 ++++++++++++ .../src/lib/plugins/examples/bulk-editor.html | 883 ++++++++++++++ .../lib/plugins/examples/change-review.html | 930 ++++++++++++++ .../plugins/examples/character-inspector.html | 1016 ++++++++++++++++ .../lib/plugins/examples/comments-inbox.html | 871 +++++++++++++ .../src/lib/plugins/examples/concordance.html | 674 ++++++++++ .../src/lib/plugins/examples/crossword.html | 727 +++++++++++ .../plugins/examples/dictionary-preview.html | 149 ++- .../plugins/examples/dictionary-stats.html | 10 +- .../lib/plugins/examples/domain-coverage.html | 800 ++++++++++++ .../plugins/examples/duplicate-finder.html | 794 ++++++++++++ .../plugins/examples/entry-time-machine.html | 980 +++++++++++++++ .../src/lib/plugins/examples/flashcards.html | 12 +- .../src/lib/plugins/examples/gap-finder.html | 825 +++++++++++++ .../src/lib/plugins/examples/index.test.ts | 31 + .../viewer/src/lib/plugins/examples/index.ts | 270 ++++- .../lib/plugins/examples/lexicon-galaxy.html | 5 +- .../lib/plugins/examples/listening-quiz.html | 939 ++++++++++++++ .../lib/plugins/examples/minimal-pairs.html | 809 ++++++++++++ .../plugins/examples/orthography-machine.html | 4 +- .../lib/plugins/examples/photo-capture.html | 1079 +++++++++++++++++ .../plugins/examples/picture-dictionary.html | 910 ++++++++++++++ .../examples/pronunciation-recorder.html | 1058 ++++++++++++++++ .../src/lib/plugins/examples/rapid-words.html | 821 +++++++++++++ .../lib/plugins/examples/reversal-index.html | 1013 ++++++++++++++++ .../lib/plugins/examples/sentence-sprint.html | 9 +- .../lib/plugins/examples/word-collector.html | 455 ------- .../plugins/examples/word-harvest-bingo.html | 6 - .../src/lib/plugins/examples/wordlist.html | 914 ++++++++++++++ .../lib/plugins/plugin-api-adapter.test.ts | 44 +- .../src/lib/plugins/plugin-api-adapter.ts | 317 ++++- .../src/lib/plugins/plugin-api-types.ts | 21 +- .../viewer/src/lib/plugins/plugin-host.ts | 3 + .../src/lib/plugins/plugin-prompt.test.ts | 59 + .../viewer/src/lib/plugins/plugin-prompt.ts | 245 +++- frontend/viewer/src/lib/plugins/plugin-sdk.js | 71 +- .../src/lib/plugins/plugin-srcdoc.test.ts | 15 +- .../viewer/src/lib/plugins/plugin-srcdoc.ts | 22 +- frontend/viewer/src/locales/en.po | 113 +- frontend/viewer/src/locales/es.po | 119 +- frontend/viewer/src/locales/fr.po | 119 +- frontend/viewer/src/locales/id.po | 119 +- frontend/viewer/src/locales/ko.po | 119 +- frontend/viewer/src/locales/ms.po | 119 +- frontend/viewer/src/locales/sw.po | 119 +- frontend/viewer/src/locales/vi.po | 119 +- .../src/project/browse/EntryMenu.svelte | 3 +- .../src/project/data/plugin-service.svelte.ts | 13 + .../src/project/demo/in-memory-demo-api.ts | 2 +- frontend/viewer/tests/plugins.test.ts | 47 +- 67 files changed, 21285 insertions(+), 764 deletions(-) create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260708143535_AddPluginDescription.Designer.cs create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260708143535_AddPluginDescription.cs create mode 100644 frontend/viewer/src/lib/plugins/examples/audio-dictionary.html create mode 100644 frontend/viewer/src/lib/plugins/examples/browse-grid.html create mode 100644 frontend/viewer/src/lib/plugins/examples/bulk-editor.html create mode 100644 frontend/viewer/src/lib/plugins/examples/change-review.html create mode 100644 frontend/viewer/src/lib/plugins/examples/character-inspector.html create mode 100644 frontend/viewer/src/lib/plugins/examples/comments-inbox.html create mode 100644 frontend/viewer/src/lib/plugins/examples/concordance.html create mode 100644 frontend/viewer/src/lib/plugins/examples/crossword.html create mode 100644 frontend/viewer/src/lib/plugins/examples/domain-coverage.html create mode 100644 frontend/viewer/src/lib/plugins/examples/duplicate-finder.html create mode 100644 frontend/viewer/src/lib/plugins/examples/entry-time-machine.html create mode 100644 frontend/viewer/src/lib/plugins/examples/gap-finder.html create mode 100644 frontend/viewer/src/lib/plugins/examples/index.test.ts create mode 100644 frontend/viewer/src/lib/plugins/examples/listening-quiz.html create mode 100644 frontend/viewer/src/lib/plugins/examples/minimal-pairs.html create mode 100644 frontend/viewer/src/lib/plugins/examples/photo-capture.html create mode 100644 frontend/viewer/src/lib/plugins/examples/picture-dictionary.html create mode 100644 frontend/viewer/src/lib/plugins/examples/pronunciation-recorder.html create mode 100644 frontend/viewer/src/lib/plugins/examples/rapid-words.html create mode 100644 frontend/viewer/src/lib/plugins/examples/reversal-index.html delete mode 100644 frontend/viewer/src/lib/plugins/examples/word-collector.html create mode 100644 frontend/viewer/src/lib/plugins/examples/wordlist.html create mode 100644 frontend/viewer/src/lib/plugins/plugin-prompt.test.ts diff --git a/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs b/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs index 29978b9dfa..6e72c69e11 100644 --- a/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs +++ b/backend/FwLite/FwLiteWeb/FwLiteWebServer.cs @@ -19,6 +19,11 @@ namespace FwLiteWeb; public static class FwLiteWebServer { + // A plugin is a single self-contained HTML document that travels whole over SignalR (the Blazor + // circuit for the local viewer, the MiniLcm hubs for remote clients). The default cap is 32KB, + // which large plugins blow past; 3MB leaves generous headroom for their inlined assets. + private const long MaxSignalRMessageSize = 3 * 1024 * 1024; + public static WebApplication SetupAppServer(WebApplicationOptions options, Action? configure = null) { var builder = WebApplication.CreateBuilder(options); @@ -49,7 +54,9 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio //todo os should be web, when the server is running remotely to the client, but linux runs the server locally so we will default using the OS to determine the platform (the default value) }); builder.Logging.AddDebug(); - builder.Services.AddRazorComponents().AddInteractiveServerComponents(circuitOptions => circuitOptions.DetailedErrors = true); + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(circuitOptions => circuitOptions.DetailedErrors = true) + .AddHubOptions(hubOptions => hubOptions.MaximumReceiveMessageSize = MaxSignalRMessageSize); if (builder.Configuration.GetValue("FwLiteWeb:EnableFileLogging", true) && builder.Configuration.GetValue("FwLiteWeb:LogFileName") is { Length: > 0 } logFileName) { @@ -67,6 +74,7 @@ public static WebApplication SetupAppServer(WebApplicationOptions options, Actio builder.Services.AddSwaggerGen(); builder.Services.AddSignalR(options => { + options.MaximumReceiveMessageSize = MaxSignalRMessageSize; options.AddFilter(new LockedProjectFilter()); options.EnableDetailedErrors = true; }).AddJsonProtocol(); diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs index 6a51c5d08b..c4b582878a 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/PluginTests.cs @@ -83,6 +83,21 @@ public async Task UpdatePlugin_AllowsManager() fetched.Html.Should().Be("v2"); } + [Fact] + public async Task Plugin_PersistsAndClearsOptionalDescription() + { + await SetCurrentUser(ManagerUserId, UserProjectRole.Manager); + var created = await Api.CreatePlugin(NewPlugin("Described") with { Description = "First summary" }); + (await Api.GetPlugin(created.Id))!.Description.Should().Be("First summary"); + + await Api.UpdatePlugin(created with { Description = "Updated summary" }); + (await Api.GetPlugin(created.Id))!.Description.Should().Be("Updated summary"); + + // An edit that omits the description clears it (the full plugin is written each time). + await Api.UpdatePlugin(created with { Description = null }); + (await Api.GetPlugin(created.Id))!.Description.Should().BeNull(); + } + [Fact] public async Task UpdatePlugin_RejectsEditor() { diff --git a/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs b/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs index 4460f27de4..908cf8f3df 100644 --- a/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/CreatePluginChange.cs @@ -11,6 +11,7 @@ public class CreatePluginChange : CreateChange, ISelfNamedType NewEntity(Commit commit, IChangeContext context) @@ -28,6 +30,7 @@ public override ValueTask NewEntity(Commit commit, IChangeContext contex { Id = EntityId, Name = Name, + Description = Description, Html = Html }); } diff --git a/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs b/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs index f72785c1d1..12075ac2a0 100644 --- a/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/EditPluginChange.cs @@ -12,6 +12,7 @@ public class EditPluginChange : EditChange, ISelfNamedType +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260708143535_AddPluginDescription")] + partial class AddPluginDescription + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("LcmCrdt.Data.UnreadComment", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("MarkedUnreadAt") + .HasColumnType("TEXT"); + + b.HasKey("CommentId"); + + b.HasIndex("CommentThreadId"); + + b.ToTable("UnreadComments"); + }); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("CommentThread"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Analysis") + .HasColumnType("jsonb"); + + b.Property("Base") + .HasColumnType("INTEGER"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExampleFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SenseFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Vernacular") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("CustomView"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("HomographNumber") + .HasColumnType("INTEGER"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Postfix") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SecondaryOrder") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Kind") + .IsUnique(); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("MorphType"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Plugin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Html") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Plugin"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsMain") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("Pictures") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("PreviousCommentId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommentThreadId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("UserComment"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + + b.HasAnnotation("CustomIndex:CompositeIndexes", "[{\"paths\":[\"HybridDateTime.DateTime\",\"HybridDateTime.Counter\",\"Id\"],\"unique\":false,\"name\":\"IX_Commits_DateTime_Counter_Id\"}]"); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CommentThread", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CustomView", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.MorphType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Plugin", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Plugin", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.HasOne("MiniLcm.Models.CommentThread", null) + .WithMany("Comments") + .HasForeignKey("CommentThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.UserComment", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Navigation("Comments"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260708143535_AddPluginDescription.cs b/backend/FwLite/LcmCrdt/Migrations/20260708143535_AddPluginDescription.cs new file mode 100644 index 0000000000..d22cb6e026 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260708143535_AddPluginDescription.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class AddPluginDescription : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "Plugin", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "Plugin"); + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs index 4bc15f61c8..aeb0ef6e41 100644 --- a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs +++ b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs @@ -448,6 +448,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeletedAt") .HasColumnType("TEXT"); + b.Property("Description") + .HasColumnType("TEXT"); + b.Property("Html") .IsRequired() .HasColumnType("TEXT"); diff --git a/backend/FwLite/MiniLcm/Models/Plugin.cs b/backend/FwLite/MiniLcm/Models/Plugin.cs index 9316351eeb..ce92089f6f 100644 --- a/backend/FwLite/MiniLcm/Models/Plugin.cs +++ b/backend/FwLite/MiniLcm/Models/Plugin.cs @@ -10,6 +10,7 @@ public record Plugin : IObjectWithId public DateTimeOffset? DeletedAt { get; set; } public required string Name { get; set; } + public string? Description { get; set; } public required string Html { get; set; } public Guid[] GetReferences() diff --git a/backend/FwLite/MiniLcm/Validators/PluginValidator.cs b/backend/FwLite/MiniLcm/Validators/PluginValidator.cs index aca3a38c9e..ab458caf28 100644 --- a/backend/FwLite/MiniLcm/Validators/PluginValidator.cs +++ b/backend/FwLite/MiniLcm/Validators/PluginValidator.cs @@ -7,12 +7,14 @@ public class PluginValidator : AbstractValidator { // Caps the commit payload so a single plugin can't bloat sync for the whole project. public const int MaxHtmlLength = 5_000_000; + public const int MaxDescriptionLength = 1_000; public PluginValidator() { RuleFor(p => p.DeletedAt).Null(); RuleFor(p => p.Name).Must(name => !string.IsNullOrWhiteSpace(name)) .WithMessage("Plugin name is required"); + RuleFor(p => p.Description).MaximumLength(MaxDescriptionLength).When(p => p.Description is not null); RuleFor(p => p.Html).Must(html => !string.IsNullOrWhiteSpace(html)) .WithMessage("Plugin HTML is required") .MaximumLength(MaxHtmlLength); diff --git a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts index 5599caade6..f3a139f8f7 100644 --- a/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts +++ b/frontend/viewer/src/lib/dotnet-types/generated-types/MiniLcm/Models/IPlugin.ts @@ -10,6 +10,7 @@ export interface IPlugin extends IObjectWithId id: string; deletedAt?: string; name: string; + description?: string; html: string; } /* eslint-enable */ diff --git a/frontend/viewer/src/lib/entry-editor/EditEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/EditEntryDialog.svelte index c37e04b922..0ca27bdfeb 100644 --- a/frontend/viewer/src/lib/entry-editor/EditEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/EditEntryDialog.svelte @@ -17,10 +17,19 @@ let { entryId, open = $bindable(false), + mode = 'edit', }: { entryId?: string, open: boolean, + /** 'view' opens read-only with an Edit button; 'edit' opens editable (default). */ + mode?: 'view' | 'edit', } = $props(); + // Own the mode internally so the in-dialog Edit button can flip it; reset to the requested mode + // whenever the dialog (re)opens. + let currentMode = $state<'view' | 'edit'>('edit'); + $effect(() => { + if (open) currentMode = mode; + }); let entryResource = resource(() => entryId, async (entryId) => { if (!entryId) return undefined; return await api.getEntry(entryId); @@ -47,7 +56,9 @@ - {$t`Update ${entryLabel}`} + + {currentMode === 'view' ? $t`View ${entryLabel}` : $t`Update ${entryLabel}`} + {#if entryResource.loading} Loading... @@ -57,14 +68,19 @@ Custom-views should perhaps be scoped to the browse-view. I'm not sure. --> - + {/if} - - + {#if currentMode === 'view'} + + + {:else} + + + {/if} diff --git a/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte b/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte index 2b1a920146..466066e296 100644 --- a/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte +++ b/frontend/viewer/src/lib/plugins/PluginAiPromptDialog.svelte @@ -2,10 +2,11 @@ import * as Dialog from '$lib/components/ui/dialog'; import {Button} from '$lib/components/ui/button'; import {Icon} from '$lib/components/ui/icon'; + import {Switch} from '$lib/components/ui/switch'; import {Textarea} from '$lib/components/ui/textarea'; import {t} from 'svelte-i18n-lingui'; import {useProjectContext} from '$project/project-context.svelte'; - import {buildPluginPrompt} from './plugin-prompt'; + import {buildPluginPrompt, type PluginPromptOptions} from './plugin-prompt'; import {AppNotification} from '$lib/notifications/notifications'; import {useBackHandler} from '$lib/utils/back-handler.svelte'; @@ -20,13 +21,26 @@ let prompt = $state(''); let copied = $state(false); + // These only steer the generated prompt; they don't restrict what a finished plugin can do. + // Defaults lean toward freedom (writes allowed, may use project data); mobile stays on by default. + const options = $state({ + projectSpecific: true, + mobile: true, + readOnly: false, + internet: false, + culturalSensitivity: false, + }); + + let generation = 0; $effect(() => { if (!open) return; + const current = {...options}; copied = false; + const token = ++generation; void buildPluginPrompt(projectContext.api, { projectName: projectContext.projectName, projectCode: projectContext.projectCode, - }).then(result => prompt = result); + }, current).then(result => { if (token === generation) prompt = result; }); }); async function copy() { @@ -51,6 +65,19 @@
  • {$t`Copy the HTML file the AI produces, then add it here via “New plugin”.`}
  • +
    +
    + {$t`These options tailor the prompt for the AI — they guide how it writes the plugin, they don't restrict what the finished plugin can do.`} +
    +
    + + + + + +
    +
    + {#if prompt} + + + + + + + +
    +
    +
    + 0 added this session +
    +
    + 0 day streak +
    +
    + +
    +   + +
    +
    + +
    +
    +1
    +
    Meaning
    +
    + +
    + +
    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    Enter to add & continue · Skip to pass without saving
    + + +
    + + +
    +
    +
    🎉
    +

    List complete!

    +

    +
    +
    0
    words added
    +
    0
    skipped
    +
    0
    day streak
    +
    +
    + + +
    +
    +
    + + + + + diff --git a/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts b/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts index 0832d1023d..e2e2a56dd9 100644 --- a/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts +++ b/frontend/viewer/src/lib/plugins/plugin-api-adapter.test.ts @@ -1,9 +1,18 @@ import {describe, expect, it} from 'vitest'; -import {toGridifyFilter} from './plugin-api-adapter'; +import {computeHeadword, toGridifyFilter} from './plugin-api-adapter'; import {PluginApiException} from './plugin-api-types'; +import {type IEntry, type IWritingSystem, MorphTypeKind} from '$lib/dotnet-types'; const POS_ID = '86ff66f6-0774-407a-a0dc-3eeaf873daf7'; +function ws(wsId: string, isAudio = false): IWritingSystem { + return {wsId, isAudio} as unknown as IWritingSystem; +} + +function entry(fields: Partial): IEntry { + return {lexemeForm: {}, citationForm: {}, morphType: MorphTypeKind.Stem, ...fields} as IEntry; +} + describe('toGridifyFilter', () => { it('returns undefined for no filter or an empty filter', () => { expect(toGridifyFilter(undefined)).toBeUndefined(); @@ -49,3 +58,36 @@ describe('toGridifyFilter', () => { expect(() => toGridifyFilter({partOfSpeechId: 'not-a-guid'})).toThrow(PluginApiException); }); }); + +describe('computeHeadword', () => { + const vernacular = [ws('seh'), ws('seh-fonipa')]; + const suffixTokens = {[MorphTypeKind.Suffix]: {prefix: '-', postfix: undefined}}; + + it('prefers the (undecorated) citation form of the default writing system', () => { + const result = computeHeadword( + entry({citationForm: {seh: 'nyumba'}, lexemeForm: {seh: 'yumba'}, morphType: MorphTypeKind.Suffix}), + vernacular, suffixTokens); + expect(result).toBe('nyumba'); + }); + + it('decorates the lexeme form with morph-type affix tokens when there is no citation form', () => { + const result = computeHeadword( + entry({lexemeForm: {seh: 's'}, morphType: MorphTypeKind.Suffix}), vernacular, suffixTokens); + expect(result).toBe('-s'); + }); + + it('does not decorate a plain stem', () => { + expect(computeHeadword(entry({lexemeForm: {seh: 'yumba'}}), vernacular, suffixTokens)).toBe('yumba'); + }); + + it('skips audio writing systems so a media reference never becomes the headword', () => { + const result = computeHeadword( + entry({lexemeForm: {'seh-audio': 'audio-ref.wav', seh: 'yumba'}}), + [ws('seh-audio', true), ws('seh')], suffixTokens); + expect(result).toBe('yumba'); + }); + + it('returns an empty string when nothing is set', () => { + expect(computeHeadword(entry({}), vernacular, suffixTokens)).toBe(''); + }); +}); diff --git a/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts b/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts index fa92a3db87..d4407c38a1 100644 --- a/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts +++ b/frontend/viewer/src/lib/plugins/plugin-api-adapter.ts @@ -1,6 +1,10 @@ -import {type IEntry, type IMiniLcmJsInvokable, MorphTypeKind, SortField} from '$lib/dotnet-types'; +import {ActivitySort, type IEntry, type IMiniLcmJsInvokable, type IWritingSystem, MorphTypeKind, SortField, SubjectType} from '$lib/dotnet-types'; import type {IQueryOptions} from '$lib/dotnet-types'; +import type {IUploadFileResponse} from '$lib/dotnet-types/generated-types/MiniLcm/Media/IUploadFileResponse'; +import type {IHistoryServiceJsInvokable} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable'; import { + KNOWN_OPEN_ENTRY_MODES, + type OpenEntryMode, PluginApiException, type PluginEntryFilter, type PluginEntryQuery, @@ -11,13 +15,21 @@ import type {PluginStorage} from './plugin-local-data'; export interface PluginHostCallbacks { /** Shows the write to the user; resolves true only if they approve it. */ confirmWrite(operation: PluginWriteOperation): Promise; - openEntry(entryId: string): void; + openEntry(entryId: string, mode: OpenEntryMode): void; notify(message: string): void; } +/** Entries handed to plugins carry a computed, read-only `headword` for convenience. */ +export type PluginEntry = IEntry & {headword: string}; + +type MorphTokens = Partial>; + const DEFAULT_ENTRY_LIMIT = 100; const MAX_ENTRY_LIMIT = 1000; const MAX_SUMMARY_LINES = 25; +const MAX_BATCH_OPERATIONS = 200; +const DEFAULT_ACTIVITY_TAKE = 50; +const MAX_ACTIVITY_TAKE = 500; /** * Implements the plugin-facing API (v1) as a thin, deliberately small adapter over the MiniLcm @@ -29,20 +41,41 @@ export class PluginApiAdapter { private api: IMiniLcmJsInvokable, private storage: PluginStorage, private callbacks: PluginHostCallbacks, + private historyService?: IHistoryServiceJsInvokable, ) {} + /** Loaded once per plugin session; writing systems and morph tokens don't change mid-run. */ + #headwordDeps?: Promise<{vernacular: IWritingSystem[]; morphTokens: MorphTokens}>; + handle(method: string, args: unknown[]): Promise { switch (method) { case 'getWritingSystems': return this.api.getWritingSystems(); case 'getEntries': return this.getEntries(asQuery(args[0])); case 'countEntries': return this.countEntries(asQuery(args[0])); - case 'getEntry': return this.api.getEntry(asId(args[0])); + case 'getEntry': return this.getEntry(asId(args[0])); case 'getPartsOfSpeech': return this.api.getPartsOfSpeech(); case 'getSemanticDomains': return this.api.getSemanticDomains(); + case 'getMedia': return this.getMedia(asNonEmptyString(args[0], 'mediaUri')); + case 'saveFile': return this.saveFile(args[0], args[1]); case 'createEntry': return this.createEntry(args[0]); case 'updateEntry': return this.updateEntry(args[0], args[1]); - case 'openEntry': return Promise.resolve(this.callbacks.openEntry(asId(args[0]))); + case 'applyChanges': return this.applyChanges(args[0]); + case 'openEntry': return Promise.resolve(this.callbacks.openEntry(asId(args[0]), asOpenEntryMode(args[1]))); case 'notify': return Promise.resolve(this.callbacks.notify(asNonEmptyString(args[0], 'message'))); + // Comments (read-only) + case 'getCommentThreads': return this.getCommentThreads(args[0]); + case 'getCommentThread': return this.api.getCommentThread(asId(args[0])); + case 'getUserComments': return this.api.getUserComments(asId(args[0])); + case 'getUnreadComments': return this.api.getUnreadComments(asOptionalId(readField(args[0], 'threadId'))); + case 'getUnreadCommentsForSubject': return this.getUnreadCommentsForSubject(args[0]); + case 'countUnreadComments': return this.api.countUnreadComments(asOptionalId(readField(args[0], 'threadId'))); + // Activity / history (read-only) + case 'getActivity': return this.getActivity(args[0]); + case 'getEntityHistory': return this.history().getHistory(asId(args[0])); + case 'getChangeContext': return this.getChangeContext(args[0]); + case 'getObjectAtCommit': return this.getObjectAtCommit(args[0]); + case 'listActivityAuthors': return this.history().listActivityAuthors(); + case 'listActivityChangeTypes': return this.history().listActivityChangeTypes(); case 'storageGet': return Promise.resolve(this.storage.get(asNonEmptyString(args[0], 'key'))); case 'storageSet': return Promise.resolve(this.storage.set(asNonEmptyString(args[0], 'key'), args[1])); case 'storageRemove': return Promise.resolve(this.storage.remove(asNonEmptyString(args[0], 'key'))); @@ -50,42 +83,229 @@ export class PluginApiAdapter { } } - private async getEntries(query: PluginEntryQuery): Promise { + private async getEntries(query: PluginEntryQuery): Promise { const options = toQueryOptions(query); - if (query.search) return await this.api.searchEntries(query.search, options); - return await this.api.getEntries(options); + const entries = query.search + ? await this.api.searchEntries(query.search, options) + : await this.api.getEntries(options); + return await this.withHeadwords(entries); + } + + private async getEntry(id: string): Promise { + const entry = await this.api.getEntry(id); + if (!entry) return null; + return (await this.withHeadwords([entry]))[0]; } private async countEntries(query: PluginEntryQuery): Promise { return await this.api.countEntries(query.search || undefined, {filter: toGridifyFilter(query.filter)}); } - private async createEntry(input: unknown): Promise { - if (typeof input !== 'object' || input === null) { - throw new PluginApiException('invalid-args', 'createEntry requires an entry object'); + /** + * Fetches the bytes for a media reference (an audio writing-system value, or a picture's + * mediaUri). The app downloads the file automatically if needed; returns null when it can't be + * had (offline, or not found), so plugins can degrade instead of crashing. + */ + private async getMedia(mediaUri: string): Promise<{data: ArrayBuffer; fileName?: string; mimeType?: string} | null> { + const file = await this.api.getFileStream(mediaUri); + if (!file.stream) return null; + const data = await file.stream.arrayBuffer(); + return {data, fileName: file.fileName, mimeType: guessMimeType(file.fileName)}; + } + + /** + * Stores a file (e.g. a recording or captured image) and returns its {@link IUploadFileResponse} + * with a `mediaUri` you then write into an entry field (via updateEntry) to attach it. The bytes + * themselves are stored locally; the entry edit that references them is still user-approved. + */ + private async saveFile(data: unknown, metadata: unknown): Promise { + if (!(data instanceof ArrayBuffer) && !(data instanceof Uint8Array) && !(data instanceof Blob)) { + throw new PluginApiException('invalid-args', 'saveFile requires the file bytes as an ArrayBuffer, Uint8Array, or Blob'); + } + if (typeof metadata !== 'object' || metadata === null) { + throw new PluginApiException('invalid-args', 'saveFile requires metadata {filename, mimeType}'); + } + const filename = asNonEmptyString((metadata as {filename?: unknown}).filename, 'filename'); + const mimeType = asNonEmptyString((metadata as {mimeType?: unknown}).mimeType, 'mimeType'); + return await this.api.saveFile(data, {filename, mimeType}); + } + + private getCommentThreads(input: unknown) { + const {subjectType, subjectId} = input as {subjectType?: unknown; subjectId?: unknown}; + const includeComments = (input as {includeComments?: unknown})?.includeComments; + return this.api.getCommentThreads(asSubjectType(subjectType), asId(subjectId), includeComments === true); + } + + private getUnreadCommentsForSubject(input: unknown) { + const {subjectType, subjectId} = input as {subjectType?: unknown; subjectId?: unknown}; + return this.api.getUnreadCommentsForSubject(asSubjectType(subjectType), asId(subjectId)); + } + + private getActivity(input: unknown) { + const query = (input ?? {}) as {skip?: unknown; take?: unknown; authorFilterKeys?: unknown; changeTypeKeys?: unknown; sort?: unknown}; + const skip = Math.max(asOptionalNumber(query.skip) ?? 0, 0); + const take = Math.min(Math.max(asOptionalNumber(query.take) ?? DEFAULT_ACTIVITY_TAKE, 1), MAX_ACTIVITY_TAKE); + return this.history().projectActivity(skip, take, asOptionalStringArray(query.authorFilterKeys), asOptionalStringArray(query.changeTypeKeys), asActivitySort(query.sort)); + } + + private getChangeContext(input: unknown) { + const {commitId, changeIndex} = input as {commitId?: unknown; changeIndex?: unknown}; + return this.history().loadChangeContext(asId(commitId), asOptionalNumber(changeIndex) ?? 0); + } + + private getObjectAtCommit(input: unknown) { + const {commitId, entityId} = input as {commitId?: unknown; entityId?: unknown}; + return this.history().getObject(asId(commitId), asId(entityId)); + } + + /** History lives on a separate service that isn't available for every project type. */ + private history(): IHistoryServiceJsInvokable { + if (!this.historyService) { + throw new PluginApiException('not-supported', 'Activity/history is not available for this project'); + } + return this.historyService; + } + + /** Attaches the app's headword (citation form, else morph-decorated lexeme form) to each entry. */ + private async withHeadwords(entries: IEntry[]): Promise { + const {vernacular, morphTokens} = await this.headwordDeps(); + return entries.map(entry => Object.assign(entry, {headword: computeHeadword(entry, vernacular, morphTokens)})); + } + + private headwordDeps(): Promise<{vernacular: IWritingSystem[]; morphTokens: MorphTokens}> { + return this.#headwordDeps ??= Promise.all([this.api.getWritingSystems(), this.api.getMorphTypes()]) + .then(([writingSystems, morphTypes]) => { + const morphTokens: MorphTokens = {}; + for (const morphType of morphTypes) morphTokens[morphType.kind] = {prefix: morphType.prefix, postfix: morphType.postfix}; + return {vernacular: writingSystems.vernacular, morphTokens}; + }); + } + + private async createEntry(input: unknown): Promise { + const prepared = this.prepareCreate(input); + if (!await this.callbacks.confirmWrite(prepared.operation)) { + throw new PluginApiException('permission-denied', 'The user declined this change'); } - const entry = normalizeNewEntry(input as Partial); + return prepared.apply(); + } + + private async updateEntry(beforeInput: unknown, afterInput: unknown): Promise { + const prepared = this.prepareUpdate(beforeInput, afterInput); + if (!prepared) return (await this.withHeadwords([stripHeadword(afterInput as IEntry)]))[0]; // nothing changed + if (!await this.callbacks.confirmWrite(prepared.operation)) { + throw new PluginApiException('permission-denied', 'The user declined this change'); + } + return prepared.apply(); + } + + /** Applies several creates/updates behind a SINGLE approval dialog, then runs them in order. */ + private async applyChanges(input: unknown): Promise { + if (!Array.isArray(input)) throw new PluginApiException('invalid-args', 'applyChanges requires an array of operations'); + if (input.length > MAX_BATCH_OPERATIONS) { + throw new PluginApiException('invalid-args', `Too many operations in one batch (max ${MAX_BATCH_OPERATIONS})`); + } + // Prepare (validate + summarize) everything up front so a bad op fails before anything is applied. + const prepared = input.flatMap(op => { + const p = this.prepareOperation(op); + return p ? [p] : []; + }); + if (prepared.length === 0) return []; const approved = await this.callbacks.confirmWrite({ - kind: 'createEntry', - entry, - summary: describeEntry(entry), + kind: 'batch', + count: prepared.length, + summary: buildBatchSummary(prepared.map(p => p.operation)), }); - if (!approved) throw new PluginApiException('permission-denied', 'The user declined this change'); - return await this.api.createEntry(entry, {includeComplexFormsAndComponents: false, autoAddMainPublication: true}); + if (!approved) throw new PluginApiException('permission-denied', 'The user declined these changes'); + const results: PluginEntry[] = []; + for (const p of prepared) results.push(await p.apply()); + return results; + } + + private prepareOperation(op: unknown): {operation: PluginWriteOperation; apply: () => Promise} | null { + if (typeof op !== 'object' || op === null) throw new PluginApiException('invalid-args', 'Each operation must be an object'); + const type = (op as {type?: unknown}).type; + if (type === 'createEntry') return this.prepareCreate((op as {entry?: unknown}).entry); + if (type === 'updateEntry') { + const {before, after} = op as {before?: unknown; after?: unknown}; + return this.prepareUpdate(before, after); + } + throw new PluginApiException('invalid-args', `Unknown operation type: ${String(type)}`); } - private async updateEntry(beforeInput: unknown, afterInput: unknown): Promise { - const before = beforeInput as IEntry; - const after = afterInput as IEntry; + private prepareCreate(input: unknown): {operation: PluginWriteOperation; apply: () => Promise} { + if (typeof input !== 'object' || input === null) { + throw new PluginApiException('invalid-args', 'createEntry requires an entry object'); + } + const entry = normalizeNewEntry(stripHeadword(input as Partial)); + return { + operation: {kind: 'createEntry', entry, summary: describeEntry(entry)}, + apply: async () => (await this.withHeadwords([ + await this.api.createEntry(entry, {includeComplexFormsAndComponents: false, autoAddMainPublication: true}), + ]))[0], + }; + } + + private prepareUpdate(beforeInput: unknown, afterInput: unknown): {operation: PluginWriteOperation; apply: () => Promise} | null { + // Drop the computed `headword` so it never reaches the real diff/apply — it's not a model field. + const before = stripHeadword(beforeInput as IEntry); + const after = stripHeadword(afterInput as IEntry); if (!before?.id || !after?.id || before.id !== after.id) { throw new PluginApiException('invalid-args', 'updateEntry requires before and after versions of the same entry'); } const summary = diffSummary(before, after); - if (summary.length === 0) return after; - const approved = await this.callbacks.confirmWrite({kind: 'updateEntry', before, after, summary}); - if (!approved) throw new PluginApiException('permission-denied', 'The user declined this change'); - return await this.api.updateEntry(before, after); + if (summary.length === 0) return null; + return { + operation: {kind: 'updateEntry', before, after, summary}, + apply: async () => (await this.withHeadwords([await this.api.updateEntry(before, after)]))[0], + }; + } +} + +/** headword = citationForm (undecorated), else lexemeForm with the morph type's affix tokens. */ +export function computeHeadword(entry: IEntry, vernacular: IWritingSystem[], morphTokens: MorphTokens): string { + for (const ws of vernacular) { + if (ws.isAudio) continue; + const citation = entry.citationForm?.[ws.wsId]; + if (citation) return citation; + const lexeme = entry.lexemeForm?.[ws.wsId]; + if (lexeme) { + const token = morphTokens[entry.morphType]; + return `${token?.prefix ?? ''}${lexeme}${token?.postfix ?? ''}`; + } + } + return ''; +} + +function stripHeadword(entry: T): T { + if (entry && typeof entry === 'object' && 'headword' in entry) { + const {headword: _headword, ...rest} = entry as Record; + return rest as T; + } + return entry; +} + +const MIME_BY_EXTENSION: Record = { + mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', m4a: 'audio/mp4', + aac: 'audio/aac', flac: 'audio/flac', webm: 'audio/webm', opus: 'audio/opus', + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', + bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff', tiff: 'image/tiff', +}; + +/** Best-effort MIME from the file extension, so plugins can build a typed Blob for