From c48a80b1da0af56043f1f20ca318a2a60703dab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 11:56:37 +0000 Subject: [PATCH] Revamp 6.1: Extension registry + MT providers as built-in addons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start Milestone 6 with the typed in-process extension registry (D9); the manifest/permission contract is final now so future sandboxed extensions never migrate. - core/extensions/types.ts: ExtensionManifest {id,name,version,kinds, permissions,builtIn}; kinds mt-provider|qa-rule|panel|import-format| export-format|storage-connector; permissions network|credentials| storage|clipboard|filesystem. - core/extensions/registry.ts: dependency-free singleton (register/list/ isEnabled/setEnabled/subscribe); enablement as a disabled set (registered = enabled by default). - core/extensions/builtins.ts: the four MT manifests + registerBuiltins. - src/extensions/mt: thin wrappers pairing each manifest to its existing core/mt provider (no provider logic moved — existing MT tests untouched). - core/mt.enabledProviders + useMTSettings AND the user setting with the registry (reactive via useSyncExternalStore) so disabling an addon removes the provider from the panel. - Persistence: extensions.disabled settings key; registered from main.tsx. - Tests: registry unit + mt-through-registry (disable removes provider). No Add-ons page yet (6.2). Green: 682 unit (+9) / mt-flow e2e / typecheck / build (flag-off Supabase-free). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015AEnTYBecWG64R3iP8GBiE --- docs/revamp/ROADMAP.md | 2 +- docs/revamp/STATUS.md | 5 +- src/core/extensions/builtins.ts | 64 +++++++++++++ src/core/extensions/registry.ts | 101 +++++++++++++++++++++ src/core/extensions/types.ts | 45 +++++++++ src/core/mt/index.ts | 31 ++++++- src/extensions/mt/index.ts | 34 +++++++ src/features/addons/registryPersistence.ts | 31 +++++++ src/features/editor/mt/useMTSettings.ts | 22 +++-- src/main.tsx | 6 ++ src/storage/repositories/settingsRepo.ts | 2 + tests/unit/extensions.mt.test.ts | 56 ++++++++++++ tests/unit/extensions.registry.test.ts | 66 ++++++++++++++ 13 files changed, 452 insertions(+), 13 deletions(-) create mode 100644 src/core/extensions/builtins.ts create mode 100644 src/core/extensions/registry.ts create mode 100644 src/core/extensions/types.ts create mode 100644 src/extensions/mt/index.ts create mode 100644 src/features/addons/registryPersistence.ts create mode 100644 tests/unit/extensions.mt.test.ts create mode 100644 tests/unit/extensions.registry.test.ts diff --git a/docs/revamp/ROADMAP.md b/docs/revamp/ROADMAP.md index 2aa3b04..0df8798 100644 --- a/docs/revamp/ROADMAP.md +++ b/docs/revamp/ROADMAP.md @@ -87,7 +87,7 @@ Roadmap, status checklist, prompt pack, and vision docs committed to the repo. - **5.3 Approval + attribution in versioning — S — deps: 5.2.** `ProjectVersion.authorId?/approval?`; revisor/PM "Sign off" creates a labeled approval version; one stable author identity across marks, comments, versions, presence. **DoD**: sign-off flow; attribution unified. *Milestone 5 = roles shipped → **web v2 launch candidate**.* *Shipped: `ProjectVersion.authorId`/`approval`; `versionRepo` stamps `authorId` (the shared `ensureLocalAuthor` identity) on every capture + a `signOff()` that records a labeled approval version; `useCanReview` gates a **Sign off** button in `VersionHistoryPanel`, which badges approval versions. No migration (versions are local Dexie). **Milestone 5 complete.*** ### Milestone 6 — Extensions & connectors -- **6.1 Extension registry + MT providers as built-in addons — M — deps: 0.1.** `core/extensions/types.ts` (`ExtensionManifest {id, name, version, kinds, permissions, builtIn}`; kinds: `mt-provider|qa-rule|panel|import-format|export-format|storage-connector`), `registry.ts` (enablement persisted in settings), `builtins.ts`; `src/extensions/mt/*` manifest wrappers delegating to existing `src/core/mt/*` (no logic moves); `mt/index.ts` resolves via registry. Existing MT tests untouched prove behavior preservation. **DoD**: four MT providers run through registry; disabling removes from panel. +- **6.1 Extension registry + MT providers as built-in addons — M — deps: 0.1.** `core/extensions/types.ts` (`ExtensionManifest {id, name, version, kinds, permissions, builtIn}`; kinds: `mt-provider|qa-rule|panel|import-format|export-format|storage-connector`), `registry.ts` (enablement persisted in settings), `builtins.ts`; `src/extensions/mt/*` manifest wrappers delegating to existing `src/core/mt/*` (no logic moves); `mt/index.ts` resolves via registry. Existing MT tests untouched prove behavior preservation. **DoD**: four MT providers run through registry; disabling removes from panel. *Shipped: `core/extensions/{types,registry,builtins}.ts` (dependency-free singleton, enablement as a disabled set, `subscribe`) + `src/extensions/mt` wrappers; `core/mt` `enabledProviders` + `useMTSettings` AND the user setting with the registry (reactive via `useSyncExternalStore`); disabled set persisted under the device-local `extensions.disabled` key, registered from `main.tsx`. No Add-ons page yet (6.2).* - **6.2 QA rules + formats as addons + Add-ons page — M — deps: 6.1.** Wrap `QA_CODES` rules and XLIFF/TMX/TBX/CSV/DOCX-export as contributions; `features/addons/AddonsPage.tsx` (catalogue, enable/disable, permission display, "suggested/built-in" UX); `/addons` route + nav. QA falls back to all-on when registry empty (keeps tests). **DoD**: Add-ons page lists everything pluggable; toggling a QA addon changes QA output. - **6.3 Google Drive connector — M — deps: 6.1, 2.4.** `src/extensions/connectors/gdrive/` (Google Identity Services token client, `drive.file` scope — avoids verification burden; REST list/download/upload; token in memory/session only), generic `ConnectorFilePicker`; "From cloud" in ImportDialog + "Save to cloud" on DOCX export. Pure client OAuth — works for local-only users too. **DoD**: import DOCX from Drive; export back to Drive. - **6.4 OneDrive connector — S — deps: 6.3.** `src/extensions/connectors/onedrive/` via dynamic `@azure/msal-browser` (`loginPopup` — avoids redirect/hash-router interplay), Graph `Files.ReadWrite`. **DoD**: same loop on OneDrive; both connectors appear as addons with permission labels. *Milestone 6 = extensions + connectors shipped.* diff --git a/docs/revamp/STATUS.md b/docs/revamp/STATUS.md index c65f934..455f257 100644 --- a/docs/revamp/STATUS.md +++ b/docs/revamp/STATUS.md @@ -35,8 +35,8 @@ Rules for autonomous sessions: | 4.4.1 | Remote caret overlay (live cursors, deferred from 4.4) | S | 4.4 | done (PR #59) | | 5.1 | Members & roles management | M | 4.1 | done (PR #60) | | 5.2 | Role-gated editing workflow | M | 5.1, 1.6 | done (PR #61) | -| 5.3 | Approval workflow + attribution in versioning | S | 5.2 | in-review (PR #62) | -| 6.1 | Extension registry + MT providers as built-in addons | M | 0.1 | pending | +| 5.3 | Approval workflow + attribution in versioning | S | 5.2 | done (PR #62) | +| 6.1 | Extension registry + MT providers as built-in addons | M | 0.1 | in-review (PR #63) | | 6.2 | QA rules + formats as addons + Add-ons page | M | 6.1 | pending | | 6.3 | Google Drive connector | M | 6.1, 2.4 | pending | | 6.4 | OneDrive connector | S | 6.3 | pending | @@ -78,3 +78,4 @@ Rules for autonomous sessions: - 2026-07-20 — Phase 4.4.1 merged in PR #59. Phase 5.1 built: members & roles management (start of **Milestone 5**, roles & workflow). `supabase/migrations/0008_member_policies.sql` — adds `profiles.email` (backfilled from `auth.users` + seeded on signup) and a co-member `profiles` SELECT policy via a new `private.shares_project(other)` SECURITY DEFINER helper (the "broader read for member lookup" 0001 deferred to 5.1); switches `project_members` **update/delete to project_manager-only** (`private.has_project_role`), keeping `insert` reachable by the owner's publish self-seed OR a PM; a `enforce_min_one_pm` BEFORE UPDATE/DELETE trigger that blocks removing/demoting a project's **last** manager; and the `invite_member(project_id, email, role)` RPC (SECURITY DEFINER, PM-checked) that resolves the email → user id and upserts membership, returning NULL when no account matches (no id/email enumeration surface). Numbering deviation: ROADMAP §5.1 sketched `0006_member_policies.sql`, but `0006`/`0007` were taken (4.1 helpers + 4.3 compaction), so it ships as `0008`; and invite resolves the email **inside** the RPC rather than a client-side profiles lookup, keeping it server-side. `src/storage/cloud/members.ts` — pure client-injected `listMembers` (joins the roster to profiles for names/emails, marks self, sorts managers first), `inviteMember` (RPC → `{status:'invited'|'not_found'}`), `changeMemberRole`/`removeMember` (direct writes gated by PM-only RLS + the trigger, surfaced as thrown errors). UI: `features/projects/cloud/ManageMembersDialog.tsx` (roster; a PM gets an invite-by-email form + per-member role `` (+ read-only badge for non-PMs) lives in `ManageMembersDialog`. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-5-2`; content-level rules are client-enforced per D8 (the append-only authored log is the audit trail), stated honestly. `docs/cloud.md` (§3 migration list + §12 manual role-matrix). Tests: `workflow.rules` (full role × stage × status matrix incl. reviewed-segment suggest, final lock, local-only PM-of-self, `forcesSuggesting`), `cloud.projectCloud` extended (stage read defaults to translation + `setCloudProjectStage` update). Verified: flag-OFF `dist` has **zero** `GoTrueClient`; the tracked-changes e2e still green (local-only = PM keeps accept/reject). Green: 671 unit (+10) / 8 tracked-changes e2e / build. No new e2e (ROADMAP §5.2 asks none; the full role matrix needs three real accounts, per the §12 manual matrix). - 2026-07-21 — Phase 5.2 merged in PR #61. Phase 5.3 built: approval workflow + unified attribution in versioning (**Milestone 5 complete → web v2 launch candidate**). `ProjectVersion` gains `authorId?` (the stable `profile.identity` author id — the *same* identity tracked-change marks, comments, and presence use, so attribution is unified across the app, D8) and `approval?: { authorId, authorName, at }`. `versionRepo.capture` now stamps `authorId` on **every** version (via `ensureLocalAuthor()`), and a new `versionRepo.signOff(projectId, liveDoc?)` records a `named` version labeled `Approved by {name}` with the `approval` metadata set (its presence marks an approval milestone). Wiring: `useWorkflowStore` gains a `useCanReview()` selector (revisor/PM; local-only → PM-of-self → true); `VersionHistoryPanel` shows a **Sign off** button beside Save version only when `canReview`, and renders approval versions with an accent `BadgeCheck` + their `Approved by …` label. No migration + no schema bump (versions are local Dexie; `authorId`/`approval` are additive unindexed fields; pre-5.3 rows simply lack them). Deviations (noted): built on the designated session branch, not `claude/revamp-phase-5-3`; sign-off gating is client-enforced via the workflow rules at the call site (D8) — the append-only authored update log remains the tamper-evident audit trail. Tests: `versionRepo` extended (every capture stamps a stable authorId consistent across captures; `signOff` records a labeled approval version whose `approval.authorId`/`at` match the version's `authorId`/`createdAt`, while a plain `saveNamed` has no `approval`). Verified: flag-OFF `dist` has **zero** `GoTrueClient`; the version-history e2e still green. Green: 673 unit (+2) / 1 version e2e / build. No new e2e (ROADMAP §5.3 asks none). **Milestone 5 (roles & workflow) complete — the web v2 launch candidate.** +- 2026-07-21 — Phase 5.3 merged in PR #62. Phase 6.1 built: the typed extension registry + the four MT providers re-registered as built-in addons (start of **Milestone 6**, extensions & connectors; D9 — in-process registry now, sandboxed hosting later, but the manifest contract is **final**). `src/core/extensions/types.ts` — `ExtensionManifest {id, name, version, kinds, permissions, builtIn, description?}`; `ExtensionKind = mt-provider|qa-rule|panel|import-format|export-format|storage-connector`; `ExtensionPermission = network|credentials|storage|clipboard|filesystem`. `src/core/extensions/registry.ts` — a dependency-free (no storage/React imports) singleton `extensionRegistry`: `register`/`unregister`/`get`/`has`/`list(kind?)` (id-sorted), enablement as a **disabled set** so a registered extension is enabled by default and an unknown id is treated as enabled (behaviour unchanged until something is explicitly turned off), `isEnabled`/`setEnabled`/`getDisabledIds`/`setDisabledIds`, and `subscribe`. `src/core/extensions/builtins.ts` — the four MT manifests (`mt.mymemory`/`mt.libretranslate`/`mt.ollama`/`mt.claude`; permissions `network` + `credentials` for the keyed ones) + idempotent `registerBuiltinExtensions()`. `src/extensions/mt/index.ts` — thin **wrappers** pairing each manifest to its existing `core/mt` provider (**no provider logic moved** — the existing MT unit tests are untouched and keep proving behaviour) + `registryEnabledMtProviderIds()`. `core/mt/index.ts` gains `MT_EXTENSION_ID` + `isProviderExtensionEnabled(id)` (defaults enabled when the manifest isn't registered), and `enabledProviders` now ANDs the user setting with the registry — so **disabling the addon removes the provider**; `useMTSettings.enabledIds` does the same and re-derives via `useSyncExternalStore(extensionRegistry.subscribe)` so the MT panel updates live. Wiring: `features/addons/registryPersistence.ts` (`registerExtensions()` sync + `startExtensionRegistry()` hydrating the disabled set from the new device-local `extensions.disabled` settings key and persisting subsequent changes), started from `main.tsx` before render. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-6-1`; no Add-ons **page** yet (that's 6.2) — 6.1 ships the registry + reactive resolution and proves the disable→panel path via unit tests. `docs/cloud.md` unchanged (no migration). Tests: `extensions.registry` (register/list-by-kind/default-enabled/toggle/unknown-id/hydrate+notify/unregister), `extensions.mt` (the four register as `mt-provider`; wrappers point at the real `MT_PROVIDERS`; **disabling `mt.claude` removes it from `enabledProviders` even with the user setting on** — the DoD — and re-enabling restores it). Verified: flag-OFF `dist` Supabase-free; the untouched MT unit tests + `mt-flow` e2e stay green. Green: 682 unit (+9) / 3 mt-flow e2e / build. No new e2e (ROADMAP §6.1 asks none). diff --git a/src/core/extensions/builtins.ts b/src/core/extensions/builtins.ts new file mode 100644 index 0000000..88a5b87 --- /dev/null +++ b/src/core/extensions/builtins.ts @@ -0,0 +1,64 @@ +import type { ExtensionManifest } from './types' +import { extensionRegistry } from './registry' + +/** + * Built-in extension manifests (ROADMAP §6.1). The four MT providers are the + * first capabilities re-expressed as manifests; QA rules, import/export formats, + * and storage connectors follow in 6.2/6.3. Manifests are pure data — the actual + * provider logic stays in `src/core/mt/*` and is paired to a manifest by the thin + * wrappers in `src/extensions/mt`. + */ + +/** Manifest ids for the four MT providers, matching `MT_EXTENSION_ID` in core/mt. */ +export const MT_EXTENSION_MANIFESTS: ExtensionManifest[] = [ + { + id: 'mt.mymemory', + name: 'MyMemory', + version: '1.0.0', + kinds: ['mt-provider'], + permissions: ['network'], + builtIn: true, + description: 'Free web translation-memory MT.', + }, + { + id: 'mt.libretranslate', + name: 'LibreTranslate', + version: '1.0.0', + kinds: ['mt-provider'], + permissions: ['network', 'credentials'], + builtIn: true, + description: 'Open-source MT (self-hosted or hosted API).', + }, + { + id: 'mt.ollama', + name: 'Ollama', + version: '1.0.0', + kinds: ['mt-provider'], + permissions: ['network'], + builtIn: true, + description: 'Local LLM translation via an Ollama server.', + }, + { + id: 'mt.claude', + name: 'Claude', + version: '1.0.0', + kinds: ['mt-provider'], + permissions: ['network', 'credentials'], + builtIn: true, + description: 'Anthropic Claude translation.', + }, +] + +let registered = false + +/** Register every built-in manifest. Idempotent — safe to call on each startup. */ +export function registerBuiltinExtensions(): void { + if (registered) return + registered = true + for (const manifest of MT_EXTENSION_MANIFESTS) extensionRegistry.register(manifest) +} + +/** Test helper: allow re-registration after a registry reset. */ +export function __resetBuiltinsForTest(): void { + registered = false +} diff --git a/src/core/extensions/registry.ts b/src/core/extensions/registry.ts new file mode 100644 index 0000000..6c1309d --- /dev/null +++ b/src/core/extensions/registry.ts @@ -0,0 +1,101 @@ +import type { ExtensionKind, ExtensionManifest } from './types' + +/** + * In-process typed extension registry (ROADMAP §6.1, D9). + * + * Holds the manifests of every registered contribution and each one's + * enable/disable state, and notifies subscribers on any change so the UI stays + * live. Deliberately dependency-free (no storage / React imports) so it is + * trivially unit-testable and usable from `core`; persistence of the disabled + * set is wired separately (see `features/addons/registryPersistence`). Enablement + * is stored as the *disabled* set, so a freshly registered extension is enabled + * by default and an unknown id is treated as enabled — behaviour is unchanged + * until something is explicitly turned off. + */ + +type Listener = () => void + +class ExtensionRegistry { + private manifests = new Map() + private disabled = new Set() + private listeners = new Set() + + /** Register (or replace) a manifest. */ + register(manifest: ExtensionManifest): void { + this.manifests.set(manifest.id, manifest) + this.emit() + } + + /** Remove a manifest (and any disabled flag for it). */ + unregister(id: string): void { + const had = this.manifests.delete(id) + this.disabled.delete(id) + if (had) this.emit() + } + + get(id: string): ExtensionManifest | undefined { + return this.manifests.get(id) + } + + has(id: string): boolean { + return this.manifests.has(id) + } + + /** All manifests (optionally of one kind), id-sorted for a stable list order. */ + list(kind?: ExtensionKind): ExtensionManifest[] { + const all = [...this.manifests.values()] + return (kind ? all.filter((m) => m.kinds.includes(kind)) : all).sort((a, b) => + a.id.localeCompare(b.id), + ) + } + + /** A registered, not-disabled extension. Unregistered ids are not enabled. */ + isEnabled(id: string): boolean { + return this.manifests.has(id) && !this.disabled.has(id) + } + + /** Enable/disable a registered extension. No-op (no notify) when unchanged. */ + setEnabled(id: string, on: boolean): void { + if (!this.manifests.has(id)) return + let changed = false + if (on) { + changed = this.disabled.delete(id) + } else if (!this.disabled.has(id)) { + this.disabled.add(id) + changed = true + } + if (changed) this.emit() + } + + /** The ids currently disabled (for persistence). */ + getDisabledIds(): string[] { + return [...this.disabled].sort() + } + + /** Replace the disabled set wholesale (for hydration from persistence). */ + setDisabledIds(ids: string[]): void { + this.disabled = new Set(ids) + this.emit() + } + + /** Subscribe to any registry change; returns an unsubscribe. */ + subscribe(fn: Listener): () => void { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + + /** Test helper: clear all state. */ + __resetForTest(): void { + this.manifests.clear() + this.disabled.clear() + this.listeners.clear() + } + + private emit(): void { + for (const fn of this.listeners) fn() + } +} + +/** The process-wide registry singleton. */ +export const extensionRegistry = new ExtensionRegistry() +export type { ExtensionRegistry } diff --git a/src/core/extensions/types.ts b/src/core/extensions/types.ts new file mode 100644 index 0000000..9292695 --- /dev/null +++ b/src/core/extensions/types.ts @@ -0,0 +1,45 @@ +/** + * Extension manifest contract (ROADMAP §6, D9). + * + * Verbalis v1 hosts extensions **in-process** through a typed registry — no + * sandbox yet — but the manifest/permission shape is **final now**, so future + * sandboxed (iframe/worker RPC) third-party extensions declare themselves the + * same way and never migrate. Built-in capabilities (MT providers first, QA + * rules + import/export formats + connectors next) are re-expressed as manifests + * so everything pluggable is described uniformly and can be listed, permission- + * inspected, and enabled/disabled from one place (the 6.2 Add-ons page). + */ + +/** What an extension contributes. One extension may contribute several kinds. */ +export type ExtensionKind = + | 'mt-provider' + | 'qa-rule' + | 'panel' + | 'import-format' + | 'export-format' + | 'storage-connector' + +/** A capability an extension needs — surfaced to the user before enabling it. */ +export type ExtensionPermission = + | 'network' // makes outbound network requests + | 'credentials' // stores/uses an API key or token + | 'storage' // reads/writes local project data + | 'clipboard' + | 'filesystem' // reads/writes files (import/export) + +export interface ExtensionManifest { + /** Stable, namespaced id, e.g. `mt.claude`. Unique across the registry. */ + id: string + /** Human-facing name shown in the Add-ons page. */ + name: string + /** Semver of the contribution (independent of the app version). */ + version: string + /** The capability kinds this extension contributes. */ + kinds: ExtensionKind[] + /** Permissions the extension needs, shown before enabling. */ + permissions: ExtensionPermission[] + /** True for capabilities that ship with the app (not user-installed). */ + builtIn: boolean + /** One-line description for the catalogue. */ + description?: string +} diff --git a/src/core/mt/index.ts b/src/core/mt/index.ts index 8905adf..c2566b4 100644 --- a/src/core/mt/index.ts +++ b/src/core/mt/index.ts @@ -1,4 +1,5 @@ import type { MTProviderId, MTSettings } from '@/core/types' +import { extensionRegistry } from '@/core/extensions/registry' import { ollamaProvider } from './ollama' import { claudeProvider } from './claude' import { libreTranslateProvider } from './libretranslate' @@ -27,6 +28,25 @@ export const MT_PROVIDERS: Record = { libretranslate: libreTranslateProvider as MTProvider, } +/** The extension-registry manifest id for each MT provider (ROADMAP §6.1). */ +export const MT_EXTENSION_ID: Record = { + mymemory: 'mt.mymemory', + ollama: 'mt.ollama', + claude: 'mt.claude', + libretranslate: 'mt.libretranslate', +} + +/** + * Whether a provider's built-in extension is enabled. Defaults to enabled when + * the manifest isn't registered yet (e.g. before startup or in a unit test that + * doesn't register built-ins), so behaviour is unchanged until an addon is + * explicitly disabled from the Add-ons page. + */ +export function isProviderExtensionEnabled(id: MTProviderId): boolean { + const extId = MT_EXTENSION_ID[id] + return !extensionRegistry.has(extId) || extensionRegistry.isEnabled(extId) +} + export function getProviderSettings(id: MTProviderId, settings: MTSettings): ProviderSettings { switch (id) { case 'mymemory': @@ -41,11 +61,14 @@ export function getProviderSettings(id: MTProviderId, settings: MTSettings): Pro } export function enabledProviders(settings: MTSettings): MTProviderId[] { + // A provider is available only when its user setting is on AND its built-in + // extension is enabled in the registry (§6.1) — disabling the addon removes it. const ids: MTProviderId[] = [] - if (settings.mymemory.enabled) ids.push('mymemory') - if (settings.ollama.enabled) ids.push('ollama') - if (settings.claude.enabled) ids.push('claude') - if (settings.libretranslate.enabled) ids.push('libretranslate') + if (settings.mymemory.enabled && isProviderExtensionEnabled('mymemory')) ids.push('mymemory') + if (settings.ollama.enabled && isProviderExtensionEnabled('ollama')) ids.push('ollama') + if (settings.claude.enabled && isProviderExtensionEnabled('claude')) ids.push('claude') + if (settings.libretranslate.enabled && isProviderExtensionEnabled('libretranslate')) + ids.push('libretranslate') return ids } diff --git a/src/extensions/mt/index.ts b/src/extensions/mt/index.ts new file mode 100644 index 0000000..b3fef15 --- /dev/null +++ b/src/extensions/mt/index.ts @@ -0,0 +1,34 @@ +import { MT_PROVIDERS, MT_EXTENSION_ID, type MTProvider } from '@/core/mt' +import { MT_EXTENSION_MANIFESTS } from '@/core/extensions/builtins' +import { extensionRegistry } from '@/core/extensions/registry' +import type { ExtensionManifest } from '@/core/extensions/types' +import type { MTProviderId } from '@/core/types' + +/** + * MT providers as built-in addons (ROADMAP §6.1). Thin **wrappers** that pair + * each existing `core/mt` provider with its manifest — **no provider logic moves + * here** (the existing MT unit tests keep proving behaviour). This is the + * addon-shaped view the registry and the future Add-ons page consume. + */ + +export interface MTProviderExtension { + manifest: ExtensionManifest + provider: MTProvider +} + +const EXT_TO_PROVIDER: Record = Object.fromEntries( + (Object.entries(MT_EXTENSION_ID) as [MTProviderId, string][]).map(([id, ext]) => [ext, id]), +) + +/** Every MT provider addon, manifest paired to its core provider. */ +export const mtProviderExtensions: MTProviderExtension[] = MT_EXTENSION_MANIFESTS.map((manifest) => ({ + manifest, + provider: MT_PROVIDERS[EXT_TO_PROVIDER[manifest.id]], +})) + +/** The MT provider ids whose addon is currently enabled in the registry. */ +export function registryEnabledMtProviderIds(): MTProviderId[] { + return mtProviderExtensions + .filter((e) => extensionRegistry.isEnabled(e.manifest.id)) + .map((e) => EXT_TO_PROVIDER[e.manifest.id]) +} diff --git a/src/features/addons/registryPersistence.ts b/src/features/addons/registryPersistence.ts new file mode 100644 index 0000000..e14a2bf --- /dev/null +++ b/src/features/addons/registryPersistence.ts @@ -0,0 +1,31 @@ +import { extensionRegistry } from '@/core/extensions/registry' +import { registerBuiltinExtensions } from '@/core/extensions/builtins' +import { settingsRepo, EXTENSIONS_SETTINGS_KEY } from '@/storage/repositories/settingsRepo' + +/** + * Wires the (dependency-free) extension registry to persistence (ROADMAP §6.1). + * Registers the built-in manifests synchronously so consumers see them + * immediately, then hydrates the disabled set from settings and persists any + * later enable/disable back to Dexie. Device-local (the disabled set is not in + * the 3.3 cloud-sync allowlist). + */ + +let started = false + +/** Register built-ins now (sync) so the registry is populated before first render. */ +export function registerExtensions(): void { + registerBuiltinExtensions() +} + +/** Hydrate enablement from settings and keep it persisted. Idempotent. */ +export async function startExtensionRegistry(): Promise { + if (started) return + started = true + registerBuiltinExtensions() + const disabled = (await settingsRepo.get(EXTENSIONS_SETTINGS_KEY)) ?? [] + extensionRegistry.setDisabledIds(disabled) + // Persist every subsequent change (fire-and-forget; Dexie stays the record). + extensionRegistry.subscribe(() => { + void settingsRepo.set(EXTENSIONS_SETTINGS_KEY, extensionRegistry.getDisabledIds()) + }) +} diff --git a/src/features/editor/mt/useMTSettings.ts b/src/features/editor/mt/useMTSettings.ts index 63843ce..15e4e4b 100644 --- a/src/features/editor/mt/useMTSettings.ts +++ b/src/features/editor/mt/useMTSettings.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useSyncExternalStore } from 'react' import { useLiveQuery } from 'dexie-react-hooks' import type { MTProviderId, MTSettings } from '@/core/types' import { @@ -6,7 +6,8 @@ import { mergeMTSettings, settingsRepo, } from '@/storage/repositories/settingsRepo' -import { resolveDefaultProvider } from '@/core/mt' +import { resolveDefaultProvider, isProviderExtensionEnabled } from '@/core/mt' +import { extensionRegistry } from '@/core/extensions/registry' export function useMTSettings(): { settings: MTSettings @@ -17,15 +18,24 @@ export function useMTSettings(): { () => settingsRepo.get>(MT_SETTINGS_KEY), [], ) + // Re-derive when an MT addon is enabled/disabled in the registry (§6.1), so + // disabling one removes it from the panel live. + const registryVersion = useSyncExternalStore( + (cb) => extensionRegistry.subscribe(cb), + () => extensionRegistry.getDisabledIds().join(','), + () => '', + ) return useMemo(() => { const settings = mergeMTSettings(stored) const defaultProvider = resolveDefaultProvider(settings) const enabledIds: MTProviderId[] = [] - if (settings.ollama.enabled) enabledIds.push('ollama') - if (settings.claude.enabled) enabledIds.push('claude') - if (settings.libretranslate.enabled) enabledIds.push('libretranslate') + if (settings.ollama.enabled && isProviderExtensionEnabled('ollama')) enabledIds.push('ollama') + if (settings.claude.enabled && isProviderExtensionEnabled('claude')) enabledIds.push('claude') + if (settings.libretranslate.enabled && isProviderExtensionEnabled('libretranslate')) + enabledIds.push('libretranslate') return { settings, defaultProvider, enabledIds } - }, [stored]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stored, registryVersion]) } export async function persistMTSettings(next: MTSettings): Promise { diff --git a/src/main.tsx b/src/main.tsx index a3c0b7c..5eebb9b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,9 +5,15 @@ import './styles/globals.css' import { registerPWA } from './pwa/register' import { maybeHandleAuthRedirect } from './storage/cloud/authBootstrap' import { isCloudConfigured } from './storage/cloud/supabaseClient' +import { registerExtensions, startExtensionRegistry } from './features/addons/registryPersistence' registerPWA() +// Register the built-in extension manifests (MT providers, §6.1) before render so +// the registry is populated for the MT panel, then hydrate enablement + persist. +registerExtensions() +void startExtensionRegistry() + // Start the background cloud reconcilers (ROADMAP §3.3 settings, §3.4 personal // term bank + TM) only when the cloud is configured, loading their code (and the // auth store they drive) lazily so the local-only bundle is unchanged. They pull diff --git a/src/storage/repositories/settingsRepo.ts b/src/storage/repositories/settingsRepo.ts index 5ce57cf..e71ceda 100644 --- a/src/storage/repositories/settingsRepo.ts +++ b/src/storage/repositories/settingsRepo.ts @@ -17,6 +17,8 @@ export const EDITOR_SETTINGS_KEY = 'editor.prefs' export const PROFILE_SETTINGS_KEY = 'profile.identity' export const WEB_SEARCH_SETTINGS_KEY = 'websearch.providers' export const SPELL_SETTINGS_KEY = 'spell.dicts' +/** Disabled extension ids (ROADMAP §6.1). Device-local; not in the 3.3 sync allowlist. */ +export const EXTENSIONS_SETTINGS_KEY = 'extensions.disabled' /** On-device spell-check. The active dictionary follows the project target lang. */ export interface SpellSettings { diff --git a/tests/unit/extensions.mt.test.ts b/tests/unit/extensions.mt.test.ts new file mode 100644 index 0000000..8700ec0 --- /dev/null +++ b/tests/unit/extensions.mt.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { MTSettings } from '@/core/types' +import { enabledProviders, MT_PROVIDERS } from '@/core/mt' +import { extensionRegistry } from '@/core/extensions/registry' +import { registerBuiltinExtensions, __resetBuiltinsForTest } from '@/core/extensions/builtins' +import { mtProviderExtensions, registryEnabledMtProviderIds } from '@/extensions/mt' + +/** All four MT providers turned on at the user-settings level. */ +const ALL_ON: MTSettings = { + default: 'mymemory', + mymemory: { enabled: true }, + ollama: { enabled: true, baseUrl: '', model: '' }, + claude: { enabled: true, apiKey: '', model: '' }, + libretranslate: { enabled: true, baseUrl: '', apiKey: '' }, +} as unknown as MTSettings + +beforeEach(() => { + extensionRegistry.__resetForTest() + __resetBuiltinsForTest() + registerBuiltinExtensions() +}) + +describe('MT providers as built-in addons (§6.1)', () => { + it('registers the four MT providers as mt-provider manifests', () => { + const ids = extensionRegistry.list('mt-provider').map((m) => m.id) + expect(ids).toEqual(['mt.claude', 'mt.libretranslate', 'mt.mymemory', 'mt.ollama']) + }) + + it('wraps each manifest onto its existing core provider (no logic moved)', () => { + for (const ext of mtProviderExtensions) { + expect(ext.provider).toBe(MT_PROVIDERS[ext.provider.id]) + expect(ext.manifest.kinds).toContain('mt-provider') + } + }) + + it('runs providers through the registry: all enabled by default', () => { + expect(enabledProviders(ALL_ON).sort()).toEqual( + ['claude', 'libretranslate', 'mymemory', 'ollama'].sort(), + ) + expect(registryEnabledMtProviderIds().sort()).toEqual( + ['claude', 'libretranslate', 'mymemory', 'ollama'].sort(), + ) + }) + + it('disabling an addon removes it from the resolved providers (the DoD)', () => { + extensionRegistry.setEnabled('mt.claude', false) + // claude is still enabled in user settings, but its addon is off. + expect(enabledProviders(ALL_ON)).not.toContain('claude') + expect(enabledProviders(ALL_ON).sort()).toEqual(['libretranslate', 'mymemory', 'ollama'].sort()) + expect(registryEnabledMtProviderIds()).not.toContain('claude') + + // Re-enabling brings it back. + extensionRegistry.setEnabled('mt.claude', true) + expect(enabledProviders(ALL_ON)).toContain('claude') + }) +}) diff --git a/tests/unit/extensions.registry.test.ts b/tests/unit/extensions.registry.test.ts new file mode 100644 index 0000000..c3b959b --- /dev/null +++ b/tests/unit/extensions.registry.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { extensionRegistry } from '@/core/extensions/registry' +import type { ExtensionManifest } from '@/core/extensions/types' + +function manifest(id: string, kind: ExtensionManifest['kinds'][number]): ExtensionManifest { + return { id, name: id, version: '1.0.0', kinds: [kind], permissions: [], builtIn: true } +} + +beforeEach(() => extensionRegistry.__resetForTest()) + +describe('ExtensionRegistry', () => { + it('registers, gets, and lists (id-sorted, filterable by kind)', () => { + extensionRegistry.register(manifest('mt.b', 'mt-provider')) + extensionRegistry.register(manifest('mt.a', 'mt-provider')) + extensionRegistry.register(manifest('qa.x', 'qa-rule')) + + expect(extensionRegistry.get('mt.a')?.name).toBe('mt.a') + expect(extensionRegistry.list('mt-provider').map((m) => m.id)).toEqual(['mt.a', 'mt.b']) + expect(extensionRegistry.list().map((m) => m.id)).toEqual(['mt.a', 'mt.b', 'qa.x']) + }) + + it('treats a registered extension as enabled by default, and toggles it', () => { + extensionRegistry.register(manifest('mt.a', 'mt-provider')) + expect(extensionRegistry.isEnabled('mt.a')).toBe(true) + + extensionRegistry.setEnabled('mt.a', false) + expect(extensionRegistry.isEnabled('mt.a')).toBe(false) + expect(extensionRegistry.getDisabledIds()).toEqual(['mt.a']) + + extensionRegistry.setEnabled('mt.a', true) + expect(extensionRegistry.isEnabled('mt.a')).toBe(true) + expect(extensionRegistry.getDisabledIds()).toEqual([]) + }) + + it('treats an unregistered id as not enabled and ignores setEnabled on it', () => { + expect(extensionRegistry.isEnabled('missing')).toBe(false) + extensionRegistry.setEnabled('missing', false) + expect(extensionRegistry.getDisabledIds()).toEqual([]) + }) + + it('hydrates the disabled set and notifies subscribers on change', () => { + extensionRegistry.register(manifest('mt.a', 'mt-provider')) + const listener = vi.fn() + const off = extensionRegistry.subscribe(listener) + + extensionRegistry.setDisabledIds(['mt.a']) + expect(extensionRegistry.isEnabled('mt.a')).toBe(false) + expect(listener).toHaveBeenCalledTimes(1) + + // No-op change does not notify. + extensionRegistry.setEnabled('mt.a', false) + expect(listener).toHaveBeenCalledTimes(1) + + off() + extensionRegistry.setEnabled('mt.a', true) + expect(listener).toHaveBeenCalledTimes(1) // unsubscribed + }) + + it('unregister removes the manifest and clears its disabled flag', () => { + extensionRegistry.register(manifest('mt.a', 'mt-provider')) + extensionRegistry.setEnabled('mt.a', false) + extensionRegistry.unregister('mt.a') + expect(extensionRegistry.has('mt.a')).toBe(false) + expect(extensionRegistry.getDisabledIds()).toEqual([]) + }) +})