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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/revamp/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
5 changes: 3 additions & 2 deletions docs/revamp/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `<Select>` + remove; a non-PM sees a read-only roster) opened from a new `ManageMembersButton` on the cloud project card (`CloudControls`), the dialog + members logic **lazy-loaded** so the projects route stays lean. Deviations (noted): built on the designated session branch, not `claude/revamp-phase-5-1`; the RLS/trigger/RPC enforcement is server-side, proven by the unit suite's injected-client mirror + the `docs/cloud.md` §11 manual matrix (vitest never hits real Supabase). Verified: flag-OFF `dist` has **zero** `GoTrueClient` (the members code is a lazy `members-*.js` chunk never fetched when the cloud is off / the button renders null). `docs/cloud.md` (§3 migration list + §11 manual verification). Tests: `cloud.members` (listMembers join+self+sort, empty roster short-circuit; inviteMember invited/not_found/forbidden-propagates; changeMemberRole in place; removeMember; **≥1-manager trigger error surfaces as a throw**). Green: 661 unit (+8) / build (flag-off Supabase-free). No new e2e (ROADMAP §5.1 asks none; the PM/role/RLS DoD needs two real accounts, per the §11 manual matrix).
- 2026-07-21 — Phase 5.1 merged in PR #60. Phase 5.2 built: role-gated editing workflow. `src/core/workflow/rules.ts` — the pure heart: `workflowCapabilities(role, stage, status) → {canEditDirect, mustSuggest, canResolveChanges, canReview, canManage}` (PM manages + edits through translation/review and is the only role that touches a `final` project; translator edits directly in translation but is **forced to suggest** in review, and must suggest on an already-`reviewed` segment; revisor waits during translation then edits + accepts/rejects in review; accept/reject is revisor/PM-only), plus `effectiveRoleStage(cloud)` (local-only → **PM-of-self**: project_manager/translation, every capability granted → zero regression) and `forcesSuggesting(role, stage)`. `supabase/migrations/0009_workflow.sql` — a `project_stage` enum + `projects.stage` (default `translation`) + `deadline`; writes are project_manager-only through the **existing** `projects_update` policy (no new policy). Numbering deviation: ROADMAP §5.2 sketched `0007_workflow.sql`, but `0007`/`0008` were taken (compaction + members), so it ships as `0009`. `WorkflowStage` added to `core/types`; `Project.cloud` gains `stage?`/`deadline?` (read on open). `projectCloud.ts` — `fetchCloudProject` now returns `stage`/`deadline`, `openCloudProject` stores them, and `setCloudProjectStage` + the `changeProjectStage(projectId, stage)` orchestration (writes Postgres — RLS rejects a non-PM — then patches the local `project.cloud.stage`). Wiring: a tiny `features/editor/workflow/useWorkflowStore` (default permissive PM/translation) is published by `EditorPage` from `effectiveRoleStage(project.cloud)`; `EditModeToggle` gains a `forced` prop (locked + disabled) driven by `forcesSuggesting`, and `EditorPage` pins `editMode='suggesting'` when forced; `ChangesPanel` (accept-all/reject-all + per-suggestion buttons) and `ChangeHoverCard` hide their resolve affordances when `!canResolveChanges`; a PM stage `<Select>` (+ 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).
64 changes: 64 additions & 0 deletions src/core/extensions/builtins.ts
Original file line number Diff line number Diff line change
@@ -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
}
101 changes: 101 additions & 0 deletions src/core/extensions/registry.ts
Original file line number Diff line number Diff line change
@@ -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<string, ExtensionManifest>()
private disabled = new Set<string>()
private listeners = new Set<Listener>()

/** 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 }
Loading
Loading