.');
+}
+
+async function setWork(id, type, page) {
+ await api('PUT', `/documents/${id}/work-status/${type}`, { json: { page_number: page } });
+}
+
+// Set the user's preferences server-side (authoritative — the app applies these on load).
+async function setPrefs(prefs) {
+ await api('PATCH', '/preferences', { json: prefs });
+}
+
+async function capture({ documentId: id, language }) {
+ await setPrefs(DEFAULT_PREFS); // reader shots use the default Reading Room / light look
+ await setWork(id, 'ocr', RESUME.ocr);
+ await setWork(id, 'translation', RESUME.translation);
+ await setWork(id, 'page_linking', RESUME.page_linking);
+
+ const req = createRequire(pathToFileURL(path('..', '..', 'wordkeep-client') + '/').href);
+ const pw = await import(pathToFileURL(req.resolve('@playwright/test')).href);
+ const chromium = pw.chromium ?? pw.default?.chromium;
+
+ await mkdir(SHOTS, { recursive: true });
+ const browser = await chromium.launch();
+ const ctx = await browser.newContext({ viewport: WORKFLOW_VIEWPORT, deviceScaleFactor: DEVICE_SCALE });
+ await ctx.addInitScript(([t, u]) => {
+ localStorage.setItem('auth_token', t); localStorage.setItem('auth_user', u);
+ }, [token, JSON.stringify(user)]);
+
+ const shot = async (name, route, { after, prefs, viewport, fullPage = false } = {}) => {
+ if (prefs) await setPrefs(prefs);
+ const page = await ctx.newPage();
+ await page.setViewportSize(viewport || WORKFLOW_VIEWPORT);
+ if (prefs) await page.addInitScript((p) => { // match initial render to avoid a theme flash
+ localStorage.setItem('wk:layout', p.layout);
+ localStorage.setItem('wk:palette', p.palette);
+ localStorage.setItem('wk:theme', p.theme_mode);
+ }, prefs);
+ process.stdout.write(`capturing ${name}… `);
+ await page.goto(APP + route, { waitUntil: 'networkidle', timeout: 60000 });
+ await page.waitForTimeout(3500);
+ if (after) await after(page);
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage });
+ await page.close();
+ console.log('done');
+ };
+
+ // Workflow order: text extraction → page linking → book view → translation.
+ await shot('extraction', `/extraction/${id}`);
+ await shot('page-linking', `/page-linking/${id}`);
+ await shot('book-view', `/book-view/${id}`, { after: async (page) => {
+ // Drive to the first reader spread so both panes show text.
+ const num = page.locator('input[type="number"]').first();
+ if (await num.count()) { await num.fill('1'); await num.press('Enter'); }
+ else { const prev = page.getByRole('button', { name: /prev/i }); for (let i = 0; i < 15 && (await prev.count()); i++) await prev.first().click({ timeout: 1000 }).catch(() => {}); }
+ await page.waitForTimeout(2500);
+ } });
+ // Translation columns scroll inside fixed-height panels. Expand those panels (and their
+ // ancestors) to their natural content height, then full-page capture, so both columns show
+ // their entire text ending naturally — no crop, no dead space.
+ await shot('translation', `/translation/${id}/${language}`, { viewport: TRANSLATION_VIEWPORT });
+ await shot('dashboard-reading-room', `/dashboard`, { prefs: DASH_LIGHT, viewport: DASH_VIEWPORT });
+ await shot('dashboard-console-dark', `/dashboard`, { prefs: DASH_DARK, viewport: DASH_VIEWPORT });
+
+ await setPrefs(DEFAULT_PREFS); // restore a sane look on the account
+ await browser.close();
+ console.log(`\nShots written to assets/shots/. Rebuild the booklet: node build.mjs`);
+}
+
+// ---- run ----
+await login();
+const target = flag('--seed') ? await seed() : await resolveTarget();
+await capture(target);
diff --git a/docs/acquisition-booklet/dist/booklet.html b/docs/acquisition-booklet/dist/booklet.html
new file mode 100644
index 0000000..043254d
--- /dev/null
+++ b/docs/acquisition-booklet/dist/booklet.html
@@ -0,0 +1,1220 @@
+
+
+
+
+WordKeep — Acquisition Brief
+
+
+
+
+
+
+ Confidential Product Acquisition Brief · 2026
+
+
+
A complete, production-grade application — for acquisition
+
WordKeep
+
A book-digitization studio: AI vision OCR, transcription, page-linking, e-reader assembly & AI translation — built end to end.
+
+
+
+
+
+
+ The asset offered is the functional application and its full source, test suites and engineering
+ documentation. Product name and landing-page identity are retained by the author (see “What’s included”).
+
+
+
+
+
+
+
+
+
+
+
+
+ 01 — The product in one minute
+ One app takes a scanned book all the way to a clean, translated edition.
+
+ Most tools do a single step — OCR, or translation, or storage. WordKeep owns the
+ whole journey for one book, in one place: upload a PDF, transcribe it with AI vision, correct it in a real
+ editor, resolve how text flows across page breaks, assemble it into an e-reader, translate it, and export.
+
+
+
01
Upload
PDF in; smart analysis flags text vs scanned vs mixed pages.
+
02
OCR
Local vision model first; auto cloud fallback. Results cached per page.
+
03
Correct
WYSIWYG editor, 5-language spell-check, footnotes.
+
04
Link
Tag how paragraphs continue across each page boundary.
+
05
Assemble
Book-view e-reader with line-accurate virtual pagination.
+
06
Translate
AI translation with wrong-language detection & split handling.
+
07
Export
Clean Markdown, footnotes preserved.
+
+
+
+
+
+
Why it’s unusual
+
No competitor bundles transcription correction, cross-page paragraph linking, e-reader assembly and
+ AI translation into a single workflow. The nearest peer (Transkribus) is cloud-only, handwriting-focused,
+ and offers no translation or reader.
+
+
+
Built for digitizing old and printed books with high-fidelity
+ text — diacritics preserved, footnotes as their own numbered, linked entries, regional date/number formats,
+ and a full second-language UI already shipped.
+
+ Local-first OCR (runs free on-device) with automatic cloud fallback
+ AI translation metered per page — a natural paid lane
+ AI providers sit behind swappable service clients — OCR runs locally with cloud fallback; translation uses Gemini
+
+
+
+
+
+
+
+
+ 02 — What it does
+ A deep feature set, already built.
+
+
+
+
AI vision OCR Local LM Studio model primary, Groq cloud fallback. Bulk background processing with live progress, cancel, and per-page result cache.
+
Smart PDF analysis Detects text / scanned / mixed pages. Pulls text directly when possible (no AI needed); extracts one page at a time instead of loading the whole PDF.
+
Editing WYSIWYG (what-you-see-is-what-you-get) rich-text editor, manual transcription against the page image, per-page edit, clear, and page-ignore.
+
+
Footnotes Full CRUD with [^N] page references, insert/unlink controls, auto-renumbering, clickable superscripts, server-side validation.
+
Spell-check Spell-check in 5 languages — English, Romanian, French, German, Spanish — plus a per-user, cross-device personal dictionary.
+
Page linking Side-by-side page pairs; tag new-row vs continue-row so paragraphs reflow correctly. Progress tracking and resume.
+
+
Book view 1 or 2-page e-reader, CSS virtual pagination via getClientRects() , font scaling, saved reading position, Markdown export.
+
AI translation Bulk & per-page AI translation; paragraph-aware across page breaks — a paragraph spilling onto the next page is translated as a whole, then skipped there; cost estimate before run; wrong-language detection; manual splitting for over-long paragraphs.
+
Accounts & security Token auth, email verification, password reset/change, TOTP 2-factor with recovery codes, strict per-user data isolation.
+
+
+
+
+
+
Personalisation system — a sellable module on its own
+
6 layouts × 6 palettes , each with full light/dark token sets, plus an auto/light/dark mode control. A themable design system the buyer can keep, extend, or white-label.
+
+
+
Localization — already bilingual
+
Entire UI and backend (validation, auth, 6 transactional emails) ship in English + Romanian , with regional date/number formats and a CI guard against untranslated strings. Adding a locale is a documented, scripted process.
+
+
+
+
+
+
+
+ 03 — The product, on screen
+ Text extraction.
+
+
+
How this is built · human + AI
+
The screens that follow are real outputs, produced by a person working alongside the app’s AI — the way
+ WordKeep is meant to be used. Full automation alone doesn’t give reliable results on real books, new or old;
+ the human-in-the-loop is the point.
+
+
+
Scanned page beside the OCR editor
+
+
+
+
+
+
+ Page linking.
+
+
+
Tag how text flows across each page break
+
+
+
+
+
+
+ Book view.
+
+
+
Assembled two-page reading, with Markdown export
+
+
+
+
+
+
+ Translation.
+
+
+
English → Romanian, side by side
+
+
+
+
+
+
+ One app, two identities.
+
+ The same dashboard, re-skinned — one of 6 layouts ×
+ 6 palettes , with light/dark modes. A themable design system the buyer can keep, extend, or white-label.
+ Reading Room · light
+ Console · dark
+
+
+
+
+
+ 04 — Under the hood
+ A clean, conventional stack a team can pick up fast.
+
+
+
+
+ API Laravel 12 · PHP 8.4 · Sanctum token auth
+ Client Angular 21 SPA · TypeScript 5.9 (strict) · Transloco i18n
+ Database MySQL · 33 versioned migrations
+ AI · OCR LM Studio (local) → Groq fallback, vendor-swappable
+ AI · Translate Google Gemini, isolated behind a service client
+ PDF Ghostscript + Spatie pdf-to-image, on-demand page render
+ API docs Scramble — interactive docs at /docs/api
+ Run modes Docker (Sail) and host PHP (Herd), both documented
+
+
+
+
+
Layered, not tangled
+
Controllers stay thin: Form Requests validate, API Resources shape responses, Services
+ hold logic, Jobs run OCR/translation on a queue. Each concern lives in one predictable place.
+
+
+
Vendor-swappable AI
+
OCR and translation sit behind service clients, so a buyer can change models or providers — or add their
+ own — without touching the UI or controllers.
+
+
+
+
+
+
+
74 backend classes (models, services, jobs, controllers, resources)
+
48 Angular components & pages
+
2 languages shipped end-to-end (UI + backend + email)
+
+
+
+
+
+
+ 05 — What lowers the buyer’s risk
+ 1,767 automated tests, green on every push.
+
+ The biggest risk in buying software is the part you can’t see: is it safe to change? Here the answer is
+ demonstrable. A broad test suite plus full CI means a new owner can refactor and ship features with a safety net
+ already in place — not build one first.
+
+
+
545
Backend feature/unit tests
+
1,078
Frontend unit tests (Vitest)
+
144
End-to-end flows (Playwright)
+
+
+
+
+
+
Continuous integration on every branch
+
+ Backend suite runs on PHP 8.4 against SQLite
+ Frontend unit suite runs on Node LTS
+ End-to-end tests run against a real running app — CI installs Ghostscript, boots the Laravel API on a freshly seeded database, and drives a real Chromium browser through full user flows
+ Hardcoded-string i18n guard blocks untranslated UI text from merging
+ Failed E2E runs upload a Playwright report artifact for diagnosis
+
+
+
+
Tests are documentation
+
The 144 end-to-end flows describe how every major feature is meant to behave, click by click. For a new
+ owner that’s an executable spec — onboarding material that can’t go stale, because CI fails the moment it does.
+
+
+
+
+
+
+
+ 06 — Built to be handed over and extended
+ Code quality is the asset. It’s what makes “build more” cheap.
+
+ A working demo proves the idea. Maintainable code proves the investment : it sets how fast and
+ how safely the next owner can grow the product. WordKeep is written to that standard.
+
+
+
Enforced standards Strict TypeScript (the compiler rejects implicit any , unchecked template bindings and missing returns), Laravel Pint (PHP formatter), Prettier (TS/HTML formatter) and EditorConfig keep every file consistent.
+
Predictable patterns Every endpoint follows the same shape — Request → Service → Resource. Learn one feature and you’ve learned them all; new features slot into the existing mold.
+
Documented conventions 23 written engineering conventions (architecture, migrations, i18n, testing) ship in the repo, capturing the “how we build here” that’s normally lost in a sale.
+
+
+
+ What that buys the next owner
+
+
+ Fast onboarding. One-command setup, dual Docker/host workflows, and 22 ready-made IDE run configs.
+ Safe refactors. 1,767 tests catch regressions before they ship.
+ Cheap features. New endpoints and pages reuse established patterns and a themable UI system.
+
+
+ Scales cleanly. Queue-based jobs for heavy work; AI behind swappable service clients.
+ No lock-in. Standard, popular frameworks — talent is easy to hire; no exotic dependencies.
+ Clear path to SaaS. Per-page cost estimation already exists as a natural metering/paywall hook.
+
+
+
+
+
+
+
+ 07 — The offer & the opportunity
+ What’s included — and where it can go.
+
+
+
+
+
✓ Included in the sale
+
+ Full source — Laravel API + Angular client
+ All test suites (1,767 tests) & CI configuration
+ The 6-layout / 6-palette theming system
+ Bilingual (EN/RO) infrastructure & tooling scripts
+ Engineering documentation & setup guides
+ API documentation (Scramble)
+
+
+
+
✗ Retained by the author
+
+ The “WordKeep” name
+ The Steadfast Knight / charter landing-page identity & copy
+
+
The asset is the functionality . Branding and the
+ landing page are flavor — a buyer typically rebrands anyway. The application is structured so the identity layer
+ lifts out cleanly.
+
+
+
+ Not the whole app? Individual
+ modules are available separately — the OCR/text-extraction pipeline, the translation engine, page-linking,
+ or the theming system — whatever fits the buyer.
+
+
+ Market & positioning
+
+
+
WordKeep sits in a real gap: a self-hostable, full-pipeline book-digitization
+ studio . The closest peer, Transkribus , is cloud-only, credit-metered (~1 credit/page for recognition),
+ handwriting-focused, and has no translation and no reader .
+
+ Generic OCR tools (Adobe, Textract, Google Doc AI) do one step, cloud-only
+ Translation tools (Doclingo, Bluente) skip transcription & assembly
+ Local-LLM OCR projects match the self-hosted local-model setup, but none of the end-to-end workflow
+
+
+
+
A built-in business model
+
OCR runs free on-device; AI translation costs only cents per page. Dedicated AI book-translators charge
+ far more for the same ~300-page book — BookTranslator.ai ≈ $10–151 , Google Cloud
+ Translation ≈ $242 — while WordKeep’s own translation spend is a fraction of that.
+ Give transcription away; meter translation at a healthy margin.
+
+
+
+
+
+
+
+
+
+ 08 — Proof of a market
+ A near-identical product already has hundreds of thousands of users.
+
+
+ WordKeep isn't betting that demand exists — it already does. The closest comparable,
+ Transkribus (run by the READ-COOP cooperative), digitises historical texts with AI the same way
+ WordKeep does, and has grown into real, measurable scale.
+
+
+
400k+
Registered Transkribus users, Oct 20251
+
110M+
Historical images processed1
+
261
Member institutions, 36 countries1
+
+
+
+
+
+
And it's still climbing
+
Transkribus' registered users grew from ~235,000 to 400,000+ in a single year
+ (Oct 2024 → Oct 2025); images processed rose from ~90M to 110M over the same period.2
+ This is a live, expanding audience — not a saturated one.
+
+ ABBYY FineReader — ~17,000 organisations actively using it; ~100M installations3
+ Google Books — 40M+ books digitised across 500+ languages4
+
+
+
+
Same demand, wider product
+
These tools prove the appetite — but each does one slice. Transkribus is cloud-only, credit-metered,
+ and has no reader and no translation. WordKeep targets the very same users with the full pipeline none of
+ them offer end-to-end (see "The offer & the opportunity").
+
+
+
+
+
+
+
+
+
+ 09 — What it costs to run at scale
+ Cheap to start. Honest about scale.
+
+
+ Today the app runs at almost no marginal cost — OCR runs on a local model and both AI
+ providers sit in their free tiers. That keeps an MVP essentially free to operate, but it won't serve
+ thousands of users unchanged. Here is exactly what scaling costs, with nothing buried.
+
+
+
+
Where it starts — ≈ $0/mo
+
OCR via a local LM Studio model (one-time GPU, no per-page fee); the Groq OCR fallback and Gemini
+ translation both on free tiers. Real marginal cost at MVP scale is effectively zero.
+
+
+
The ceiling, stated plainly
+
The local OCR model serves one request stream on one machine, and the job queue is database-backed.
+ Past a handful of concurrent users that's the bottleneck — it does not scale horizontally as shipped.
+
+
+
+
+ The costed scaling path
+
+ OCR compute Move OCR to a GPU fleet running the same Qwen-VL model (~$0.39–1.01 / GPU-hr1 ) or a managed cloud OCR API (~$1.50 / 1,000 pages2 ) — the dominant variable cost at scale.
+ Queue + cache Swap the database queue for Redis so jobs process in parallel across workers.
+ AI tiers Move Groq + Gemini to paid tiers (Gemini 2.5 Flash $0.30 / $2.50 per 1M tokens in / out3 ; flash-lite ~6× cheaper as a lever).
+
+
+
+ Monthly run cost by scale
+
+ Cost line 100 users 1,000 users 10,000 users
+
+ OCR compute2 ~$45 ~$450 ~$2,700–4,500
+ Translation3 ~$45 ~$430 ~$4,300
+ Baseline infra4 ~$75 ~$300 ~$1,500
+ Monthly total ~$165 ~$1,180 ~$8,500–10,300
+
+
+ Assumes a typical active user processes ~300 OCR pages and ~150 translated pages per month — about $1 / user / month at scale. That full cost is carried into the profit scenarios overleaf, so nothing is hidden behind the projections.
+
+
+
+
+
+
+
+ 10 — Revenue, profit & scenarios
+ Three ways to earn from one codebase.
+
+
+ The same product supports three business models. Every profit figure below already absorbs the
+ full run-cost from the previous page — including the at-scale OCR and infrastructure spend — so these are
+ net of cost, not gross.
+
+
+
+
1 · Metered translation
+
Give OCR away; charge per translated page.
+
At $0.02 / page 1 — a fraction of dedicated book-translators:
+
+
+ 100 users ~$135
+ 1,000 ~$1,820
+ 10,000 ~$20,600
+
+
+
net profit / month
+
+
+
2 · Subscription
+
Flat monthly plan per paying user.
+
At $10 / user / mo 2 (paying subscribers):
+
+
+ 100 users ~$835
+ 1,000 ~$8,800
+ 10,000 ~$90,600
+
+
+
net profit / month
+
+
+
3 · Self-host license
+
Customers run their own infra; COGS ≈ 0.
+
At $3,000 / yr per institution3 :
+
+
+ 25 orgs ~$75k
+ 100 orgs ~$300k
+ 250 orgs ~$750k
+
+
+
revenue / year
+
+
+
+
+
+
How to read these numbers
+
Run-costs are grounded in current vendor pricing (previous page); the price points ($0.02/page, $10/mo,
+ $3,000/yr) are stated assumptions, set conservatively against real comparables. The scaling-infrastructure
+ cost is already inside every profit figure — there is no second bill waiting after purchase. And the models
+ combine: a free-OCR hook that meters translation, with institutional licenses alongside, all on the one codebase.
+
+
+
+
+
+
+
+
+ 11 — The road ahead
+ Where a buyer takes it next.
+
+
+ The application is complete and working as offered. What follows is the product's
+ own roadmap — directions already scoped in the project's live issue tracker, ready for the next
+ owner to pick up. None of it is needed for today's app to run; each is a place to extend it.
+
+
+
+
A richer reading studio
+
+ Automatic table of contents for digitised books
+ Zoom into the page image while transcribing
+ AI footnote explanations, plus manual footnote editing
+ Spell-check error counter with jump-to-next-error
+
+
+
+
A smarter pipeline
+
+ Fuse the PDF's embedded text layer with vision OCR for higher accuracy
+ Pull a source PDF's illustrations into the book — auto-extract embedded images, or crop them from scanned pages
+ Live translation-credit meter in the client
+
+
+
+
Output & flow
+
+ Word (.docx) export with print-ready templates (A6, A5, A4)
+ Page-number navigation in the page-linking view
+
+
+
+
Built to scale (SaaS-ready)
+
+ Planned: S3-compatible object storage (RustFS / Cloudflare R2) for cloud scale
+ Automated dependency security scanning in CI, with audit follow-through
+ Self-serve account deletion
+
+
+
+
+
+ Drawn from the project's maintained backlog — a tracked,
+ actively managed roadmap the buyer inherits alongside the code.
+
+
+
+
+
+
+ A finished, tested, extensible product — ready for its next owner.
+ Not a prototype to be rescued, but a
+ working application with the engineering discipline that makes building the next version fast and low-risk.
+
+ Prepared by David Gușă · 2026 · Contact david.gusa@gsdgroup.net
+
+ Confidential. Figures reflect the codebase at the time of writing.
+
+
+
+
diff --git a/docs/acquisition-booklet/dist/booklet.pdf b/docs/acquisition-booklet/dist/booklet.pdf
new file mode 100644
index 0000000..0d32e21
Binary files /dev/null and b/docs/acquisition-booklet/dist/booklet.pdf differ
diff --git a/docs/acquisition-booklet/fetch-fonts.mjs b/docs/acquisition-booklet/fetch-fonts.mjs
new file mode 100644
index 0000000..afe170a
--- /dev/null
+++ b/docs/acquisition-booklet/fetch-fonts.mjs
@@ -0,0 +1,37 @@
+// One-time: download the booklet's web fonts as local .woff2 files so the PDF is
+// fully self-contained and reproducible (no network at build time). Re-run only if
+// you change the FONT_CSS_URL below. Requires network access once.
+import { writeFile, mkdir } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const fontsDir = join(here, 'assets', 'fonts');
+
+const FONT_CSS_URL =
+ 'https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,900&family=Spectral:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap';
+
+// A modern desktop UA makes Google Fonts serve woff2.
+const UA =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
+
+await mkdir(fontsDir, { recursive: true });
+
+const css = await (await fetch(FONT_CSS_URL, { headers: { 'User-Agent': UA } })).text();
+
+const urls = [...css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/g)].map((m) => m[1]);
+const unique = [...new Set(urls)];
+console.log(`Found ${unique.length} woff2 files`);
+
+let localCss = css;
+let i = 0;
+for (const url of unique) {
+ const name = `font-${String(i++).padStart(2, '0')}.woff2`;
+ const buf = Buffer.from(await (await fetch(url, { headers: { 'User-Agent': UA } })).arrayBuffer());
+ await writeFile(join(fontsDir, name), buf);
+ localCss = localCss.split(url).join(`./${name}`); // rewrite remote -> local filename
+ console.log(` ${name} (${(buf.length / 1024).toFixed(0)} KB)`);
+}
+
+await writeFile(join(fontsDir, 'fonts.css'), localCss, 'utf8');
+console.log('Wrote assets/fonts/fonts.css');
diff --git a/docs/acquisition-booklet/sections/00-cover.html b/docs/acquisition-booklet/sections/00-cover.html
new file mode 100644
index 0000000..b16ddef
--- /dev/null
+++ b/docs/acquisition-booklet/sections/00-cover.html
@@ -0,0 +1,28 @@
+
+
+
+ Confidential Product Acquisition Brief · 2026
+
+
+
A complete, production-grade application — for acquisition
+
WordKeep
+
A book-digitization studio: AI vision OCR, transcription, page-linking, e-reader assembly & AI translation — built end to end.
+
+
+
+
+
+
+ The asset offered is the functional application and its full source, test suites and engineering
+ documentation. Product name and landing-page identity are retained by the author (see “What’s included”).
+
+
+
+
+
+
diff --git a/docs/acquisition-booklet/sections/05-blank.html b/docs/acquisition-booklet/sections/05-blank.html
new file mode 100644
index 0000000..6309300
--- /dev/null
+++ b/docs/acquisition-booklet/sections/05-blank.html
@@ -0,0 +1,2 @@
+
+
diff --git a/docs/acquisition-booklet/sections/10-one-minute.html b/docs/acquisition-booklet/sections/10-one-minute.html
new file mode 100644
index 0000000..7badedc
--- /dev/null
+++ b/docs/acquisition-booklet/sections/10-one-minute.html
@@ -0,0 +1,38 @@
+
+
+ One app takes a scanned book all the way to a clean, translated edition.
+
+ Most tools do a single step — OCR, or translation, or storage. WordKeep owns the
+ whole journey for one book, in one place: upload a PDF, transcribe it with AI vision, correct it in a real
+ editor, resolve how text flows across page breaks, assemble it into an e-reader, translate it, and export.
+
+
+
01
Upload
PDF in; smart analysis flags text vs scanned vs mixed pages.
+
02
OCR
Local vision model first; auto cloud fallback. Results cached per page.
+
03
Correct
WYSIWYG editor, 5-language spell-check, footnotes.
+
04
Link
Tag how paragraphs continue across each page boundary.
+
05
Assemble
Book-view e-reader with line-accurate virtual pagination.
+
06
Translate
AI translation with wrong-language detection & split handling.
+
07
Export
Clean Markdown, footnotes preserved.
+
+
+
+
+
+
Why it’s unusual
+
No competitor bundles transcription correction, cross-page paragraph linking, e-reader assembly and
+ AI translation into a single workflow. The nearest peer (Transkribus) is cloud-only, handwriting-focused,
+ and offers no translation or reader.
+
+
+
Built for digitizing old and printed books with high-fidelity
+ text — diacritics preserved, footnotes as their own numbered, linked entries, regional date/number formats,
+ and a full second-language UI already shipped.
+
+ Local-first OCR (runs free on-device) with automatic cloud fallback
+ AI translation metered per page — a natural paid lane
+ AI providers sit behind swappable service clients — OCR runs locally with cloud fallback; translation uses Gemini
+
+
+
+
diff --git a/docs/acquisition-booklet/sections/20-capabilities.html b/docs/acquisition-booklet/sections/20-capabilities.html
new file mode 100644
index 0000000..e28ef72
--- /dev/null
+++ b/docs/acquisition-booklet/sections/20-capabilities.html
@@ -0,0 +1,30 @@
+
+ A deep feature set, already built.
+
+
+
+
AI vision OCR Local LM Studio model primary, Groq cloud fallback. Bulk background processing with live progress, cancel, and per-page result cache.
+
Smart PDF analysis Detects text / scanned / mixed pages. Pulls text directly when possible (no AI needed); extracts one page at a time instead of loading the whole PDF.
+
Editing WYSIWYG (what-you-see-is-what-you-get) rich-text editor, manual transcription against the page image, per-page edit, clear, and page-ignore.
+
+
Footnotes Full CRUD with [^N] page references, insert/unlink controls, auto-renumbering, clickable superscripts, server-side validation.
+
Spell-check Spell-check in 5 languages — English, Romanian, French, German, Spanish — plus a per-user, cross-device personal dictionary.
+
Page linking Side-by-side page pairs; tag new-row vs continue-row so paragraphs reflow correctly. Progress tracking and resume.
+
+
Book view 1 or 2-page e-reader, CSS virtual pagination via getClientRects() , font scaling, saved reading position, Markdown export.
+
AI translation Bulk & per-page AI translation; paragraph-aware across page breaks — a paragraph spilling onto the next page is translated as a whole, then skipped there; cost estimate before run; wrong-language detection; manual splitting for over-long paragraphs.
+
Accounts & security Token auth, email verification, password reset/change, TOTP 2-factor with recovery codes, strict per-user data isolation.
+
+
+
+
+
+
Personalisation system — a sellable module on its own
+
6 layouts × 6 palettes , each with full light/dark token sets, plus an auto/light/dark mode control. A themable design system the buyer can keep, extend, or white-label.
+
+
+
Localization — already bilingual
+
Entire UI and backend (validation, auth, 6 transactional emails) ship in English + Romanian , with regional date/number formats and a CI guard against untranslated strings. Adding a locale is a documented, scripted process.
+
+
+
diff --git a/docs/acquisition-booklet/sections/30-screens-1.html b/docs/acquisition-booklet/sections/30-screens-1.html
new file mode 100644
index 0000000..350b60a
--- /dev/null
+++ b/docs/acquisition-booklet/sections/30-screens-1.html
@@ -0,0 +1,13 @@
+
+ Text extraction.
+
+
+
How this is built · human + AI
+
The screens that follow are real outputs, produced by a person working alongside the app’s AI — the way
+ WordKeep is meant to be used. Full automation alone doesn’t give reliable results on real books, new or old;
+ the human-in-the-loop is the point.
+
+
+
Scanned page beside the OCR editor
+
+
diff --git a/docs/acquisition-booklet/sections/31-screens-2.html b/docs/acquisition-booklet/sections/31-screens-2.html
new file mode 100644
index 0000000..016a9ef
--- /dev/null
+++ b/docs/acquisition-booklet/sections/31-screens-2.html
@@ -0,0 +1,7 @@
+
+ Page linking.
+
+
+
Tag how text flows across each page break
+
+
diff --git a/docs/acquisition-booklet/sections/32-screens-3.html b/docs/acquisition-booklet/sections/32-screens-3.html
new file mode 100644
index 0000000..4e94bd6
--- /dev/null
+++ b/docs/acquisition-booklet/sections/32-screens-3.html
@@ -0,0 +1,7 @@
+
+ Book view.
+
+
+
Assembled two-page reading, with Markdown export
+
+
diff --git a/docs/acquisition-booklet/sections/33-screens-4.html b/docs/acquisition-booklet/sections/33-screens-4.html
new file mode 100644
index 0000000..d94b2df
--- /dev/null
+++ b/docs/acquisition-booklet/sections/33-screens-4.html
@@ -0,0 +1,7 @@
+
+ Translation.
+
+
+
English → Romanian, side by side
+
+
diff --git a/docs/acquisition-booklet/sections/34-screens-5.html b/docs/acquisition-booklet/sections/34-screens-5.html
new file mode 100644
index 0000000..a7f0dd1
--- /dev/null
+++ b/docs/acquisition-booklet/sections/34-screens-5.html
@@ -0,0 +1,8 @@
+
+ One app, two identities.
+
+ The same dashboard, re-skinned — one of 6 layouts ×
+ 6 palettes , with light/dark modes. A themable design system the buyer can keep, extend, or white-label.
+
+
+
diff --git a/docs/acquisition-booklet/sections/40-architecture.html b/docs/acquisition-booklet/sections/40-architecture.html
new file mode 100644
index 0000000..429bf33
--- /dev/null
+++ b/docs/acquisition-booklet/sections/40-architecture.html
@@ -0,0 +1,37 @@
+
+ A clean, conventional stack a team can pick up fast.
+
+
+
+
+ API Laravel 12 · PHP 8.4 · Sanctum token auth
+ Client Angular 21 SPA · TypeScript 5.9 (strict) · Transloco i18n
+ Database MySQL · 33 versioned migrations
+ AI · OCR LM Studio (local) → Groq fallback, vendor-swappable
+ AI · Translate Google Gemini, isolated behind a service client
+ PDF Ghostscript + Spatie pdf-to-image, on-demand page render
+ API docs Scramble — interactive docs at /docs/api
+ Run modes Docker (Sail) and host PHP (Herd), both documented
+
+
+
+
+
Layered, not tangled
+
Controllers stay thin: Form Requests validate, API Resources shape responses, Services
+ hold logic, Jobs run OCR/translation on a queue. Each concern lives in one predictable place.
+
+
+
Vendor-swappable AI
+
OCR and translation sit behind service clients, so a buyer can change models or providers — or add their
+ own — without touching the UI or controllers.
+
+
+
+
+
+
+
74 backend classes (models, services, jobs, controllers, resources)
+
48 Angular components & pages
+
2 languages shipped end-to-end (UI + backend + email)
+
+
diff --git a/docs/acquisition-booklet/sections/50-engineering.html b/docs/acquisition-booklet/sections/50-engineering.html
new file mode 100644
index 0000000..904c69c
--- /dev/null
+++ b/docs/acquisition-booklet/sections/50-engineering.html
@@ -0,0 +1,32 @@
+
+ 1,767 automated tests, green on every push.
+
+ The biggest risk in buying software is the part you can’t see: is it safe to change? Here the answer is
+ demonstrable. A broad test suite plus full CI means a new owner can refactor and ship features with a safety net
+ already in place — not build one first.
+
+
+
545
Backend feature/unit tests
+
1,078
Frontend unit tests (Vitest)
+
144
End-to-end flows (Playwright)
+
+
+
+
+
+
Continuous integration on every branch
+
+ Backend suite runs on PHP 8.4 against SQLite
+ Frontend unit suite runs on Node LTS
+ End-to-end tests run against a real running app — CI installs Ghostscript, boots the Laravel API on a freshly seeded database, and drives a real Chromium browser through full user flows
+ Hardcoded-string i18n guard blocks untranslated UI text from merging
+ Failed E2E runs upload a Playwright report artifact for diagnosis
+
+
+
+
Tests are documentation
+
The 144 end-to-end flows describe how every major feature is meant to behave, click by click. For a new
+ owner that’s an executable spec — onboarding material that can’t go stale, because CI fails the moment it does.
+
+
+
diff --git a/docs/acquisition-booklet/sections/60-code-quality.html b/docs/acquisition-booklet/sections/60-code-quality.html
new file mode 100644
index 0000000..80127e5
--- /dev/null
+++ b/docs/acquisition-booklet/sections/60-code-quality.html
@@ -0,0 +1,27 @@
+
+ Code quality is the asset. It’s what makes “build more” cheap.
+
+ A working demo proves the idea. Maintainable code proves the investment : it sets how fast and
+ how safely the next owner can grow the product. WordKeep is written to that standard.
+
+
+
Enforced standards Strict TypeScript (the compiler rejects implicit any , unchecked template bindings and missing returns), Laravel Pint (PHP formatter), Prettier (TS/HTML formatter) and EditorConfig keep every file consistent.
+
Predictable patterns Every endpoint follows the same shape — Request → Service → Resource. Learn one feature and you’ve learned them all; new features slot into the existing mold.
+
Documented conventions 23 written engineering conventions (architecture, migrations, i18n, testing) ship in the repo, capturing the “how we build here” that’s normally lost in a sale.
+
+
+
+ What that buys the next owner
+
+
+ Fast onboarding. One-command setup, dual Docker/host workflows, and 22 ready-made IDE run configs.
+ Safe refactors. 1,767 tests catch regressions before they ship.
+ Cheap features. New endpoints and pages reuse established patterns and a themable UI system.
+
+
+ Scales cleanly. Queue-based jobs for heavy work; AI behind swappable service clients.
+ No lock-in. Standard, popular frameworks — talent is easy to hire; no exotic dependencies.
+ Clear path to SaaS. Per-page cost estimation already exists as a natural metering/paywall hook.
+
+
+
diff --git a/docs/acquisition-booklet/sections/70-offer-market.html b/docs/acquisition-booklet/sections/70-offer-market.html
new file mode 100644
index 0000000..38c82ad
--- /dev/null
+++ b/docs/acquisition-booklet/sections/70-offer-market.html
@@ -0,0 +1,59 @@
+
+ What’s included — and where it can go.
+
+
+
+
+
✓ Included in the sale
+
+ Full source — Laravel API + Angular client
+ All test suites (1,767 tests) & CI configuration
+ The 6-layout / 6-palette theming system
+ Bilingual (EN/RO) infrastructure & tooling scripts
+ Engineering documentation & setup guides
+ API documentation (Scramble)
+
+
+
+
✗ Retained by the author
+
+ The “WordKeep” name
+ The Steadfast Knight / charter landing-page identity & copy
+
+
The asset is the functionality . Branding and the
+ landing page are flavor — a buyer typically rebrands anyway. The application is structured so the identity layer
+ lifts out cleanly.
+
+
+
+ Not the whole app? Individual
+ modules are available separately — the OCR/text-extraction pipeline, the translation engine, page-linking,
+ or the theming system — whatever fits the buyer.
+
+
+ Market & positioning
+
+
+
WordKeep sits in a real gap: a self-hostable, full-pipeline book-digitization
+ studio . The closest peer, Transkribus , is cloud-only, credit-metered (~1 credit/page for recognition),
+ handwriting-focused, and has no translation and no reader .
+
+ Generic OCR tools (Adobe, Textract, Google Doc AI) do one step, cloud-only
+ Translation tools (Doclingo, Bluente) skip transcription & assembly
+ Local-LLM OCR projects match the self-hosted local-model setup, but none of the end-to-end workflow
+
+
+
+
A built-in business model
+
OCR runs free on-device; AI translation costs only cents per page. Dedicated AI book-translators charge
+ far more for the same ~300-page book — BookTranslator.ai ≈ $10–151 , Google Cloud
+ Translation ≈ $242 — while WordKeep’s own translation spend is a fraction of that.
+ Give transcription away; meter translation at a healthy margin.
+
+
+
+
+
diff --git a/docs/acquisition-booklet/sections/72-market-validation.html b/docs/acquisition-booklet/sections/72-market-validation.html
new file mode 100644
index 0000000..68ccecd
--- /dev/null
+++ b/docs/acquisition-booklet/sections/72-market-validation.html
@@ -0,0 +1,41 @@
+
+ A near-identical product already has hundreds of thousands of users.
+
+
+ WordKeep isn't betting that demand exists — it already does. The closest comparable,
+ Transkribus (run by the READ-COOP cooperative), digitises historical texts with AI the same way
+ WordKeep does, and has grown into real, measurable scale.
+
+
+
400k+
Registered Transkribus users, Oct 20251
+
110M+
Historical images processed1
+
261
Member institutions, 36 countries1
+
+
+
+
+
+
And it's still climbing
+
Transkribus' registered users grew from ~235,000 to 400,000+ in a single year
+ (Oct 2024 → Oct 2025); images processed rose from ~90M to 110M over the same period.2
+ This is a live, expanding audience — not a saturated one.
+
+ ABBYY FineReader — ~17,000 organisations actively using it; ~100M installations3
+ Google Books — 40M+ books digitised across 500+ languages4
+
+
+
+
Same demand, wider product
+
These tools prove the appetite — but each does one slice. Transkribus is cloud-only, credit-metered,
+ and has no reader and no translation. WordKeep targets the very same users with the full pipeline none of
+ them offer end-to-end (see "The offer & the opportunity").
+
+
+
+
+
diff --git a/docs/acquisition-booklet/sections/74-economics.html b/docs/acquisition-booklet/sections/74-economics.html
new file mode 100644
index 0000000..5403323
--- /dev/null
+++ b/docs/acquisition-booklet/sections/74-economics.html
@@ -0,0 +1,49 @@
+
+ Cheap to start. Honest about scale.
+
+
+ Today the app runs at almost no marginal cost — OCR runs on a local model and both AI
+ providers sit in their free tiers. That keeps an MVP essentially free to operate, but it won't serve
+ thousands of users unchanged. Here is exactly what scaling costs, with nothing buried.
+
+
+
+
Where it starts — ≈ $0/mo
+
OCR via a local LM Studio model (one-time GPU, no per-page fee); the Groq OCR fallback and Gemini
+ translation both on free tiers. Real marginal cost at MVP scale is effectively zero.
+
+
+
The ceiling, stated plainly
+
The local OCR model serves one request stream on one machine, and the job queue is database-backed.
+ Past a handful of concurrent users that's the bottleneck — it does not scale horizontally as shipped.
+
+
+
+
+ The costed scaling path
+
+ OCR compute Move OCR to a GPU fleet running the same Qwen-VL model (~$0.39–1.01 / GPU-hr1 ) or a managed cloud OCR API (~$1.50 / 1,000 pages2 ) — the dominant variable cost at scale.
+ Queue + cache Swap the database queue for Redis so jobs process in parallel across workers.
+ AI tiers Move Groq + Gemini to paid tiers (Gemini 2.5 Flash $0.30 / $2.50 per 1M tokens in / out3 ; flash-lite ~6× cheaper as a lever).
+
+
+
+ Monthly run cost by scale
+
+ Cost line 100 users 1,000 users 10,000 users
+
+ OCR compute2 ~$45 ~$450 ~$2,700–4,500
+ Translation3 ~$45 ~$430 ~$4,300
+ Baseline infra4 ~$75 ~$300 ~$1,500
+ Monthly total ~$165 ~$1,180 ~$8,500–10,300
+
+
+ Assumes a typical active user processes ~300 OCR pages and ~150 translated pages per month — about $1 / user / month at scale. That full cost is carried into the profit scenarios overleaf, so nothing is hidden behind the projections.
+
+
+
diff --git a/docs/acquisition-booklet/sections/76-projection.html b/docs/acquisition-booklet/sections/76-projection.html
new file mode 100644
index 0000000..503c2ff
--- /dev/null
+++ b/docs/acquisition-booklet/sections/76-projection.html
@@ -0,0 +1,65 @@
+
+ Three ways to earn from one codebase.
+
+
+ The same product supports three business models. Every profit figure below already absorbs the
+ full run-cost from the previous page — including the at-scale OCR and infrastructure spend — so these are
+ net of cost, not gross.
+
+
+
+
1 · Metered translation
+
Give OCR away; charge per translated page.
+
At $0.02 / page 1 — a fraction of dedicated book-translators:
+
+
+ 100 users ~$135
+ 1,000 ~$1,820
+ 10,000 ~$20,600
+
+
+
net profit / month
+
+
+
2 · Subscription
+
Flat monthly plan per paying user.
+
At $10 / user / mo 2 (paying subscribers):
+
+
+ 100 users ~$835
+ 1,000 ~$8,800
+ 10,000 ~$90,600
+
+
+
net profit / month
+
+
+
3 · Self-host license
+
Customers run their own infra; COGS ≈ 0.
+
At $3,000 / yr per institution3 :
+
+
+ 25 orgs ~$75k
+ 100 orgs ~$300k
+ 250 orgs ~$750k
+
+
+
revenue / year
+
+
+
+
+
+
How to read these numbers
+
Run-costs are grounded in current vendor pricing (previous page); the price points ($0.02/page, $10/mo,
+ $3,000/yr) are stated assumptions, set conservatively against real comparables. The scaling-infrastructure
+ cost is already inside every profit figure — there is no second bill waiting after purchase. And the models
+ combine: a free-OCR hook that meters translation, with institutional licenses alongside, all on the one codebase.
+
+
+
+
diff --git a/docs/acquisition-booklet/sections/80-future.html b/docs/acquisition-booklet/sections/80-future.html
new file mode 100644
index 0000000..6fd7135
--- /dev/null
+++ b/docs/acquisition-booklet/sections/80-future.html
@@ -0,0 +1,47 @@
+
+ Where a buyer takes it next.
+
+
+ The application is complete and working as offered. What follows is the product's
+ own roadmap — directions already scoped in the project's live issue tracker, ready for the next
+ owner to pick up. None of it is needed for today's app to run; each is a place to extend it.
+
+
+
+
A richer reading studio
+
+ Automatic table of contents for digitised books
+ Zoom into the page image while transcribing
+ AI footnote explanations, plus manual footnote editing
+ Spell-check error counter with jump-to-next-error
+
+
+
+
A smarter pipeline
+
+ Fuse the PDF's embedded text layer with vision OCR for higher accuracy
+ Pull a source PDF's illustrations into the book — auto-extract embedded images, or crop them from scanned pages
+ Live translation-credit meter in the client
+
+
+
+
Output & flow
+
+ Word (.docx) export with print-ready templates (A6, A5, A4)
+ Page-number navigation in the page-linking view
+
+
+
+
Built to scale (SaaS-ready)
+
+ Planned: S3-compatible object storage (RustFS / Cloudflare R2) for cloud scale
+ Automated dependency security scanning in CI, with audit follow-through
+ Self-serve account deletion
+
+
+
+
+
+ Drawn from the project's maintained backlog — a tracked,
+ actively managed roadmap the buyer inherits alongside the code.
+
diff --git a/docs/acquisition-booklet/sections/99-closing.html b/docs/acquisition-booklet/sections/99-closing.html
new file mode 100644
index 0000000..168178c
--- /dev/null
+++ b/docs/acquisition-booklet/sections/99-closing.html
@@ -0,0 +1,10 @@
+
+
+ A finished, tested, extensible product — ready for its next owner.
+ Not a prototype to be rescued, but a
+ working application with the engineering discipline that makes building the next version fast and low-risk.
+
+ Prepared by David Gușă · 2026 · Contact david.gusa@gsdgroup.net
+
+ Confidential. Figures reflect the codebase at the time of writing.
+
diff --git a/docs/acquisition-booklet/styles.css b/docs/acquisition-booklet/styles.css
new file mode 100644
index 0000000..1ebdb70
--- /dev/null
+++ b/docs/acquisition-booklet/styles.css
@@ -0,0 +1,218 @@
+/* WordKeep acquisition booklet — all styling lives here.
+ Fonts are provided by assets/fonts/fonts.css (generated by fetch-fonts.mjs)
+ and inlined at build time. Edit colors/type in :root and the blocks below. */
+:root{
+ --vellum:#F3ECDB;
+ --vellum-deep:#E7DBBF;
+ --paper:#FBF7EC;
+ --ink:#2A241C;
+ --wine:#5C1F26;
+ --wine-deep:#3F141A;
+ --brass:#A6812E;
+ --brass-light:#C7A24A;
+ --sepia:#6E6048;
+ --rule:#CBB98F;
+ --ocr:#7C7A3A;
+ --link:#2E6675;
+ --book:#9C5A2C;
+ --trans:#6E4A7E;
+ /* Binding gutter: extra blank space on the binding edge for the metal coil/wire
+ binding ("spires"). The booklet prints DOUBLE-SIDED, so the gutter mirrors per
+ page: LEFT on recto (odd) pages, RIGHT on verso (even) pages — see .verso rules
+ below — so the spire edge lands on the same physical side once sheets are flipped.
+ Increase if the punch-holes still clip content. */
+ --gutter:12mm;
+}
+*{box-sizing:border-box;}
+html,body{margin:0;padding:0;}
+@page{ size:A4; margin:0; }
+body{
+ font-family:"Spectral",Georgia,serif;
+ color:var(--ink);
+ background:var(--vellum);
+ font-size:10.5pt;
+ line-height:1.5;
+ -webkit-print-color-adjust:exact;
+ print-color-adjust:exact;
+}
+.page{
+ position:relative;
+ width:210mm;
+ height:297mm;
+ padding:20mm 20mm 18mm calc(20mm + var(--gutter));
+ page-break-after:always;
+ overflow:hidden;
+ background:var(--vellum);
+}
+.page:last-child{ page-break-after:auto; }
+
+/* Verso (even) pages: mirror the binding gutter to the RIGHT edge for duplex print. */
+.page.verso{ padding-left:20mm; padding-right:calc(20mm + var(--gutter)); }
+
+/* ---------- type ---------- */
+h1,h2,h3,.display{ font-family:"Fraunces",Georgia,serif; font-weight:600; line-height:1.04; margin:0; }
+.eyebrow{
+ font-family:"IBM Plex Mono",monospace;
+ font-size:8pt; letter-spacing:.28em; text-transform:uppercase;
+ color:var(--brass); margin:0 0 10px;
+}
+p{ margin:0 0 9px; }
+.lead{ font-size:12pt; line-height:1.55; color:var(--ink); }
+.muted{ color:var(--sepia); }
+.mono{ font-family:"IBM Plex Mono",monospace; }
+a{ color:var(--wine); text-decoration:none; }
+
+.rule{ height:1px; background:var(--rule); border:0; margin:14px 0; }
+.rule-brass{ height:2px; background:linear-gradient(90deg,var(--brass),transparent); border:0; }
+
+/* ---------- seal signature + footer ---------- */
+.seal{ width:64px; height:64px; }
+.pagefoot{
+ position:absolute; left:20mm; right:20mm; bottom:11mm;
+ display:flex; justify-content:space-between; align-items:center;
+ font-family:"IBM Plex Mono",monospace; font-size:7.5pt; letter-spacing:.12em;
+ color:var(--sepia); text-transform:uppercase;
+}
+.pagefoot{ left:calc(20mm + var(--gutter)); }
+/* Verso = left-hand page: reverse the footer so the folio lands on the outer (left) corner. */
+.verso .pagefoot{ left:20mm; right:calc(20mm + var(--gutter)); flex-direction:row-reverse; }
+.pagefoot .folio{ color:var(--wine); }
+
+/* ---------- cover ---------- */
+.cover{
+ background:
+ radial-gradient(120% 80% at 50% -10%, rgba(166,129,46,.16), transparent 60%),
+ var(--vellum);
+ display:flex; flex-direction:column; justify-content:space-between;
+ padding:24mm 22mm 20mm calc(22mm + var(--gutter));
+}
+.cover .topline{
+ font-family:"IBM Plex Mono",monospace; font-size:8.5pt; letter-spacing:.3em;
+ text-transform:uppercase; color:var(--sepia);
+ display:flex; justify-content:space-between;
+}
+.cover .frame{ border:1px solid var(--rule); padding:18mm 14mm; position:relative; }
+.cover .frame::before,.cover .frame::after{
+ content:""; position:absolute; width:14px; height:14px; border:1px solid var(--brass);
+}
+.cover .frame::before{ top:-1px; left:-1px; border-right:0; border-bottom:0; }
+.cover .frame::after{ bottom:-1px; right:-1px; border-left:0; border-top:0; }
+.cover h1{ font-size:58pt; font-weight:900; letter-spacing:-.01em; color:var(--wine); }
+.cover .sub{ font-size:15pt; color:var(--ink); margin-top:8px; font-style:italic; font-family:"Spectral"; }
+.cover .kicker{
+ font-family:"IBM Plex Mono",monospace; font-size:9pt; letter-spacing:.22em;
+ text-transform:uppercase; color:var(--brass); margin-bottom:18px;
+}
+.cover .metarow{ display:flex; gap:26px; margin-top:22px; }
+.cover .metarow div{ font-family:"IBM Plex Mono",monospace; font-size:8pt; letter-spacing:.06em; color:var(--sepia); text-transform:uppercase; }
+.cover .metarow b{ display:block; font-family:"Fraunces"; font-size:19pt; color:var(--wine); letter-spacing:0; text-transform:none; margin-top:3px; }
+.cover .seals{ display:flex; gap:10px; align-items:center; }
+
+/* ---------- shared blocks ---------- */
+.h-sec{ font-size:30pt; color:var(--wine); font-weight:600; }
+.two{ display:grid; grid-template-columns:1fr 1fr; gap:18px; }
+.three{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:14px; }
+.card{
+ background:var(--paper); border:1px solid var(--rule);
+ padding:13px 15px; border-radius:2px;
+}
+.card h3{ font-size:13pt; color:var(--wine-deep); margin-bottom:5px; }
+.card p{ font-size:9.5pt; color:var(--ink); margin:0; }
+.chip{
+ display:inline-block; font-family:"IBM Plex Mono",monospace; font-size:7pt;
+ letter-spacing:.1em; text-transform:uppercase; padding:2px 7px; border-radius:10px;
+ color:#fff; margin-right:5px; vertical-align:middle;
+}
+.c-ocr{ background:var(--ocr);} .c-link{ background:var(--link);}
+.c-book{ background:var(--book);} .c-trans{ background:var(--trans);}
+
+/* pipeline */
+.pipe{ display:grid; grid-template-columns:repeat(7,1fr); gap:0; margin-top:8px; }
+.pipe .step{ padding:12px 8px 12px 0; border-top:2px solid var(--brass); }
+.pipe .num{ font-family:"IBM Plex Mono",monospace; font-size:8pt; color:var(--brass); letter-spacing:.1em; }
+.pipe .nm{ font-family:"Fraunces"; font-weight:600; font-size:10.5pt; color:var(--wine-deep); margin-top:4px; }
+.pipe .ds{ font-size:8pt; color:var(--sepia); margin-top:3px; line-height:1.35; }
+
+/* metrics hero */
+.metricgrid{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:0; border-top:2px solid var(--brass); }
+.metric{ padding:16px 14px; border-right:1px solid var(--rule); }
+.metric:last-child{ border-right:0; }
+.metric .n{ font-family:"Fraunces"; font-weight:900; font-size:40pt; color:var(--wine); line-height:.9; }
+.metric .l{ font-family:"IBM Plex Mono",monospace; font-size:7.5pt; letter-spacing:.14em; text-transform:uppercase; color:var(--sepia); margin-top:8px; }
+
+table.spec{ width:100%; border-collapse:collapse; font-size:9.5pt; }
+table.spec td{ padding:6px 4px; border-bottom:1px solid var(--rule); vertical-align:top; }
+table.spec td:first-child{ font-family:"IBM Plex Mono",monospace; font-size:8pt; letter-spacing:.04em; color:var(--wine); white-space:nowrap; width:130px; text-transform:uppercase; }
+
+/* multi-column figures table (cost / profit projections) — numbers right-aligned */
+table.fig{ width:100%; border-collapse:collapse; font-size:9.5pt; }
+table.fig th, table.fig td{ padding:6px 8px; border-bottom:1px solid var(--rule); text-align:right; vertical-align:bottom; }
+table.fig th:first-child, table.fig td:first-child{ text-align:left; font-family:"IBM Plex Mono",monospace; font-size:8pt; letter-spacing:.04em; color:var(--wine); text-transform:uppercase; white-space:nowrap; }
+table.fig thead th{ color:var(--sepia); border-bottom:2px solid var(--brass); text-transform:uppercase; font-family:"IBM Plex Mono",monospace; font-size:7.5pt; letter-spacing:.1em; font-weight:400; }
+table.fig tr.total td{ font-weight:600; color:var(--wine); border-bottom:0; border-top:2px solid var(--brass); }
+
+ul.tick{ list-style:none; padding:0; margin:0; }
+ul.tick li{ position:relative; padding-left:18px; margin-bottom:6px; font-size:9.5pt; }
+ul.tick li::before{ content:"\2726"; position:absolute; left:0; color:var(--brass); font-size:8pt; top:1px; }
+
+/* screenshot plates */
+.plate{ background:var(--paper); border:1px solid var(--rule); padding:6px; }
+.plate .shot{
+ height:45mm; display:flex; align-items:center; justify-content:center;
+ background:repeating-linear-gradient(45deg,#efe6d0,#efe6d0 8px,#ece1c7 8px,#ece1c7 16px);
+ color:var(--sepia); font-family:"IBM Plex Mono",monospace; font-size:8pt; letter-spacing:.1em; text-align:center;
+ border:1px dashed var(--rule);
+}
+/* Plate height ≈ column-width / 1.6 so the 1440×900 captures show whole, uncropped. */
+.plate img{ width:100%; height:45mm; object-fit:cover; object-position:top; display:block; }
+.plate .cap{ font-family:"IBM Plex Mono",monospace; font-size:7.5pt; color:var(--wine); padding:6px 4px 2px; letter-spacing:.06em; text-transform:uppercase; }
+.plate.tall .shot, .plate.tall img{ height:128mm; }
+
+.callout{ background:var(--wine); color:#F3ECDB; padding:16px 18px; border-radius:2px; }
+.callout h3{ font-family:"Fraunces"; color:#fff; font-size:13pt; margin:0 0 5px; }
+.callout p{ font-size:9.5pt; margin:0; color:#EAD9C4; }
+
+.incl{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }
+.incl .box{ border:1px solid var(--rule); background:var(--paper); padding:14px 16px; }
+.incl .yes h3{ color:#3f6b3a; } .incl .no h3{ color:var(--wine); }
+.incl h3{ font-family:"Fraunces"; font-size:12pt; margin:0 0 8px; }
+
+/* closing */
+.closing{
+ display:flex; flex-direction:column; justify-content:center; text-align:center;
+ background:radial-gradient(120% 70% at 50% 40%, rgba(166,129,46,.14), transparent 65%), var(--vellum);
+}
+
+/* ---------- brand marks ---------- */
+.foot-brand{ display:inline-flex; align-items:center; gap:6px; }
+.wk-mark{ width:13px; height:13px; display:inline-block; vertical-align:middle; }
+.wk-mark-lg{ width:54px; height:54px; display:block; }
+.medallion{ display:block; }
+.cover .medallion{ width:124px; height:124px; }
+.closing .medallion{ width:150px; height:150px; margin:0 auto 20px; }
+
+.h-sub{ font-family:"Fraunces"; font-weight:600; font-size:22pt; color:var(--wine); }
+
+/* ---------- screenshot pages: one per page, vertically centred ---------- */
+.page.screen{ display:flex; flex-direction:column; }
+.screen-stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; justify-content:center; }
+.plate.full{ width:100%; }
+.plate.full img{ width:100%; height:auto; display:block; } /* full capture, never cropped */
+.plate.full .shot{ height:150mm; } /* placeholder fallback only */
+
+/* dashboards comparison page keeps two landscape shots stacked */
+.plate.wide img{ width:100%; height:auto; display:block; }
+.plate.wide + .plate.wide{ margin-top:12px; }
+
+/* ---------- human-in-the-loop disclaimer (prominent) ---------- */
+.disclaimer{ background:var(--wine); color:#F3ECDB; border-left:4px solid var(--brass-light);
+ padding:13px 17px; border-radius:2px; margin:2px 0 0; }
+.disclaimer .lbl{ font-family:"IBM Plex Mono",monospace; font-size:7.5pt; letter-spacing:.18em;
+ text-transform:uppercase; color:var(--brass-light); display:block; margin-bottom:5px; }
+.disclaimer p{ margin:0; font-size:10pt; color:#F0E2D0; line-height:1.5; }
+
+/* ---------- footnotes ---------- */
+sup.ref{ font-size:6.5pt; color:var(--brass); font-family:"IBM Plex Mono",monospace; padding-left:1px; }
+.footnotes{ border-top:1px solid var(--rule); margin-top:12px; padding-top:7px; font-size:7.5pt; color:var(--sepia); line-height:1.45; }
+.footnotes .fn{ margin-bottom:2px; }
+.footnotes .fn b{ color:var(--wine); font-weight:500; }
diff --git a/docs/claude-code-memories/MEMORY.md b/docs/claude-code-memories/MEMORY.md
index 585cbd4..fe12243 100644
--- a/docs/claude-code-memories/MEMORY.md
+++ b/docs/claude-code-memories/MEMORY.md
@@ -6,7 +6,7 @@
- [SQLite-compatible migrations](sqlite-compatible-migrations.md) — `Schema::table()` + Blueprint only, no raw `ALTER TABLE MODIFY`
- [Mirror memories to docs](mirror-memories-to-docs.md) — copy every memory into `docs/claude-code-memories/`
- [Modal viewport clipping](modal-viewport-clipping.md) — measure-then-position floating modals/tooltips
-- [Numeric date format](numeric-date-format.md) — DD/MM/YYYY zero-padded for numeric dates
+- [Numeric date format](numeric-date-format.md) — locale-aware: en DD/MM/YYYY, ro DD.MM.YYYY (zero-padded)
- [PHPDoc on controller methods](phpdoc-on-controller-methods.md) — title + description on every API controller method for scramble
- [Request/Resource controllers](request-resource-controllers.md) — Form Request validation + API Resource formatting
- [Commit message prefix](commit-message-prefix.md) — prefix commits with `[WKP-N]` from branch name
@@ -19,3 +19,11 @@
- [AuthService spec mocks](auth-service-spec-mocks.md) — mock `user` as `vi.fn()` in LayoutComponent specs
- [Ask plan open questions before exit](ask-plan-open-questions-before-exit.md) — resolve every open question via AskUserQuestion before ExitPlanMode
- [Never migrate:fresh without approval](never-migrate-fresh-without-approval.md) — destructive DB commands require explicit approval
+- [Frontend tests use npm test](frontend-tests-use-npm-test.md) — run via `npm test`, not `npx vitest` directly
+- [i18n Transloco architecture](i18n-transloco-architecture.md) — asset path, no TranslocoService in shared services, scope resolution, test setup
+- [Standalone scripts self-sufficient](standalone-scripts-self-sufficient.md) — embed reference data in tooling scripts, don't read app/client assets
+- [Propose better tools, quality first](propose-better-tools-quality-first.md) — flag superior tools before proceeding; never compromise quality for speed
+- [Demos realistic not idealistic](demos-realistic-not-idealistic.md) — show real representative AI output; fix inputs not outputs, don't sanitize
+- [WordKeep demo content provenance](wordkeep-demo-content-provenance.md) — guest /demo uses PD Vanity Fair scan (commercial-OK); fixture = real raw app output
+- [Post-signup discovery surprise](post-signup-discovery-surprise.md) — withhold an existing advanced feature as the post-signup reveal; no bespoke surprise mechanics
+- [User runs E2E tests](user-runs-e2e-tests.md) — don't run Playwright/E2E yourself; hand off, the user runs them
diff --git a/docs/claude-code-memories/demos-realistic-not-idealistic.md b/docs/claude-code-memories/demos-realistic-not-idealistic.md
new file mode 100644
index 0000000..c68eb79
--- /dev/null
+++ b/docs/claude-code-memories/demos-realistic-not-idealistic.md
@@ -0,0 +1,14 @@
+---
+name: demos-realistic-not-idealistic
+description: "Demos should show real, representative AI output — don't sanitize model output to look better"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: 55a6b3eb-e375-406e-9c53-e114f26fd6d9
+---
+
+For product demos (esp. AI features), prefer **realistic over idealistic**. Show genuine, *representative* model output — not best-case (looks dishonest/over-promises) and not worst-case (cherry-picks failures).
+
+**Why:** User felt that cleaning up the AI model's OCR mistakes to make the demo look better was dishonest — it overstates the product. For WordKeep specifically the pitch IS human-in-the-loop (AI + human correction), so a few *real* minor errors left in actually strengthen the demo and the editing-feature story; a flawless demo misrepresents an AI product.
+
+**How to apply:** Don't edit the model's OUTPUT to hide flaws. If output looks bad, fix the INPUT instead (e.g. the OCR errors were caused by a gutter-cropped scan, not the model — swapping to a clean scan gave honest, good output with only believable minor slips). Keep captured output verbatim. See [[wordkeep-demo-content-provenance]].
diff --git a/docs/claude-code-memories/frontend-tests-use-npm-test.md b/docs/claude-code-memories/frontend-tests-use-npm-test.md
new file mode 100644
index 0000000..5234839
--- /dev/null
+++ b/docs/claude-code-memories/frontend-tests-use-npm-test.md
@@ -0,0 +1,12 @@
+---
+name: frontend-tests-use-npm-test
+description: Run frontend unit tests via `npm test`, not `npx vitest` directly
+metadata:
+ type: feedback
+---
+
+In wordkeep-client, run unit tests with `npm test` (`ng test --watch=false`), never `npx vitest run ...` directly.
+
+**Why:** the Angular `@angular/build:unit-test` builder wires up the test environment (setupFiles like `src/test-setup/local-storage.ts`, Angular/Vitest integration) that invoking vitest directly bypasses, giving misleading results.
+
+**How to apply:** to run the whole suite use `npm test`. Avoid `npx vitest run ` for targeted runs. See [[auth-service-spec-mocks]], [[use-edit-tool-for-large-files]].
diff --git a/docs/claude-code-memories/i18n-transloco-architecture.md b/docs/claude-code-memories/i18n-transloco-architecture.md
new file mode 100644
index 0000000..43b60eb
--- /dev/null
+++ b/docs/claude-code-memories/i18n-transloco-architecture.md
@@ -0,0 +1,26 @@
+---
+name: i18n-transloco-architecture
+description: WordKeep i18n conventions and Transloco gotchas (asset path, no service injection, scope resolution, test setup)
+metadata:
+ type: project
+---
+
+Localization (WKP-36) uses **@jsverse/transloco** (frontend) + Laravel `lang/` (backend). Locales: `en` (default), `ro`. Four non-obvious conventions/traps:
+
+1. **i18n JSON lives in `wordkeep-client/public/assets/i18n/`, NOT `src/assets/i18n/`.** `angular.json` globs `public/**/*` to the asset root, so the loader fetches `/assets/i18n/.json` and `/assets/i18n//.json` from there. Putting files under `src/assets` (as Transloco docs assume) means they won't be served.
+
+2. **Never inject `TranslocoService` into a widely-depended-on service** (e.g. `UserPreferencesService`). It makes Transloco a transitive DI dependency of half the app and breaks ~20 unrelated specs with `No provider for TRANSLOCO_TRANSPILER`. The locale→Transloco bridge lives in `app.config.ts` via `provideAppInitializer` (an `effect` linking `prefs.locale()` → `transloco.setActiveLang()`). The active locale itself is a signal on `UserPreferencesService` (`locale()`, `setLocale()`, `localeId()`), persisted to `wk:locale` + `/api/preferences`.
+
+3. **Scoped resolution gotcha:** inside a route with `provideTranslocoScope('x')`, the pipe/directive default to scope `x` and do NOT fall back to global keys. Shared labels must be duplicated into each scope that needs them (or accessed only from non-scoped/global components). Lazy scopes are attached per-route in `app.routes.ts`.
+
+4. **Spec setup:** any component using the `transloco` pipe/`*transloco` directive needs `getTranslocoTestingModule()` (from `src/app/transloco/transloco-testing.ts`) in its TestBed `imports`; register every new scope's JSON there. Run unit tests with [[frontend-tests-use-npm-test]].
+
+5. **Plurals use ICU + MessageFormat.** `@jsverse/transloco-messageformat` is installed and `provideTranslocoMessageformat()` is in `app.config.ts`. Write counts as `{count, plural, one {# x} few {# y} other {# de z}}` — Romanian needs the `few` form (2–19) and `de` for 20+. Do NOT use an English `{{plural}}` `''`/`'s'` suffix. (The testing module can't register the transpiler via `forRoot`, so ICU renders literally in unit tests — fine unless a spec asserts plural text, in which case add `provideTranslocoMessageformat()` to that spec's providers.)
+
+6. **Language names live in the GLOBAL `languages.` map** (`public/assets/i18n/{en,ro}.json`). Resolve them with `this.transloco.translate('languages.' + code)` (programmatic `translate()` reads the root, unaffected by a component's ambient scope), falling back to the code when the returned value equals the key. Don't reintroduce per-component English language maps.
+
+7. **Backend (Laravel) localization.** User-facing API messages live in `lang/{en,ro}/messages.php` (domain-grouped: `auth, profile, documents, pages, ocr, translation, page_linking, footnotes, bulk_ocr`); controllers return `__('messages..')`, never hardcoded literals. EN values are kept verbatim-identical to the old literals so PHPUnit assertions (which run under default `en`) stay green. Generic `['error' => 'Not found']` 404s and `app/Exceptions`+`app/Jobs` messages are intentionally NOT localized. Validation field names come from the `attributes` array in `lang/{en,ro}/validation.php`; wrong-password replies reuse `validation.current_password`. The frontend shows `err.error?.message ?? translate('')`, so any English `message` overrides the RO fallback — that's why backend messages must be localized.
+
+8. **Queued mail must stamp the locale.** Mailables are queued (`QUEUE_CONNECTION=database`); the worker has no request context (defaults to `en`), and `HasLocalePreference` is useless when mail is sent to a string address or before the prefs row exists (registration). Always queue with `Mail::to($x)->locale(app()->getLocale())->queue(...)` so the request locale (from the `Accept-Language` interceptor) is serialized onto the job.
+
+See [[numeric-date-format]] for locale-aware dates/numbers (`en-GB`/`ro` registered in `app.config.ts`).
diff --git a/docs/claude-code-memories/numeric-date-format.md b/docs/claude-code-memories/numeric-date-format.md
index 133c145..d90662d 100644
--- a/docs/claude-code-memories/numeric-date-format.md
+++ b/docs/claude-code-memories/numeric-date-format.md
@@ -6,11 +6,11 @@ metadata:
originSessionId: dc94a6e7-98a6-4b48-9a29-a68b76e2eca0
---
-Whenever a date is displayed in numeric form (slash- or dash-separated digits), use **DD/MM/YYYY** with zero-padding on day and month — e.g. `12/04/2026`, not `4/12/2026` and not `12/4/2026`.
+Numeric dates are now **locale-aware** (WKP-36): day-first with zero-padding in both locales, only the separator changes — `en` → **DD/MM/YYYY** (`28/05/2026`), `ro` → **DD.MM.YYYY** (`28.05.2026`). Never `M/D/YYYY` or unpadded.
-**Why:** User preference. The default `toLocaleDateString()` returns locale-dependent formats (often `M/D/YYYY` in en-US), which is ambiguous and inconsistent. User wants a deterministic, unambiguous, day-first format.
+**Why:** User preference for a deterministic day-first format; RO follows its regional dot-separator convention. Default `toLocaleDateString()` returns ambiguous locale-dependent output.
**How to apply:**
-- Don't call `toLocaleDateString()` without options for numeric output. Instead use a shared helper or `toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })` (en-GB happens to match) — or a manual formatter that always pads to 2 digits.
-- Recommended utility: `wordkeep-client/src/app/utils/date-format.ts` → `formatNumericDate(iso: string): string`.
-- Only applies to numeric dates. Where the UI uses month names ("Jan 15, 2024"), leave existing locale-aware formatting alone.
+- Use `wordkeep-client/src/app/utils/date-format.ts` → `formatNumericDate(input, locale?)`. `locale` defaults to `'en'`; pass the active locale from `UserPreferencesService.locale()` for locale-correct output.
+- For Angular pipes (`DatePipe`/`DecimalPipe`), pass the locale id from `UserPreferencesService.localeId()` (`ro` or `en-GB`) — both registered via `registerLocaleData` in `app.config.ts`. RO numbers render `1.234,56`, en-GB `1,234.56`.
+- Only applies to numeric dates. Where the UI uses month names, leave locale-aware month formatting alone.
diff --git a/docs/claude-code-memories/post-signup-discovery-surprise.md b/docs/claude-code-memories/post-signup-discovery-surprise.md
new file mode 100644
index 0000000..011101f
--- /dev/null
+++ b/docs/claude-code-memories/post-signup-discovery-surprise.md
@@ -0,0 +1,12 @@
+---
+name: post-signup-discovery-surprise
+description: For demo/onboarding delight, withhold an existing advanced feature as a post-signup discovery instead of building bespoke surprise mechanics
+metadata:
+ type: feedback
+---
+
+When asked to "leave a surprise" for users after signing up, prefer **withholding an already-built advanced feature** from the guest/demo experience so it becomes a natural discovery after registration — rather than building dedicated surprise/easter-egg mechanics.
+
+**Why:** zero extra engineering, and it rewards conversion organically (the fuller product is the payoff).
+
+**How to apply:** WordKeep's guest `/demo` exposes light/dark + colour palettes (personalisation) but deliberately hides the 6 layout variants; the layout switcher (already at `profile/personalisation`) is the post-signup reveal. Pair with [[demos-realistic-not-idealistic]].
diff --git a/docs/claude-code-memories/propose-better-tools-quality-first.md b/docs/claude-code-memories/propose-better-tools-quality-first.md
new file mode 100644
index 0000000..e54fdb1
--- /dev/null
+++ b/docs/claude-code-memories/propose-better-tools-quality-first.md
@@ -0,0 +1,14 @@
+---
+name: propose-better-tools-quality-first
+description: "Before proceeding on a deliverable, flag if better tools exist; quality is top priority over speed"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: b5cfce72-45b7-4ea7-ab9d-f690f4adb950
+---
+
+On quality-sensitive deliverables, the user wants to be told if a better tool exists for the job **before** proceeding — they will install/acquire it. Do not silently work around a missing tool or accept a lower-quality path.
+
+**Why:** User said "if there are tools which are better for the job tell me... we will get them. Do not compromise on the quality of the job." Quality ranks above speed/convenience.
+
+**How to apply:** When a step has a clearly superior tool you lack, pause and recommend it (with the cheap/zero-install alternative if one exists) rather than defaulting to whatever's already installed. Then let the user choose. Verify real output, not proxies (e.g. rasterize the actual generated PDF, don't just screenshot the source HTML).
diff --git a/docs/claude-code-memories/standalone-scripts-self-sufficient.md b/docs/claude-code-memories/standalone-scripts-self-sufficient.md
new file mode 100644
index 0000000..e805319
--- /dev/null
+++ b/docs/claude-code-memories/standalone-scripts-self-sufficient.md
@@ -0,0 +1,16 @@
+---
+name: standalone-scripts-self-sufficient
+description: Build/tooling scripts must be self-contained, not read data from app/client assets
+metadata:
+ type: feedback
+---
+
+Standalone tooling scripts (e.g. `scripts/translate-i18n.mjs`) should embed their
+own reference data (e.g. a code→language-name map) rather than reading it from the
+client app's assets (e.g. `wordkeep-client/public/assets/i18n/en.json`).
+
+**Why:** keeps the script independent of app structure; it shouldn't break if
+client assets move or change shape.
+
+**How to apply:** when a script needs a lookup table, define it as a const in the
+script. Don't couple tooling to runtime/client JSON. Relates to [[i18n-transloco-architecture]].
diff --git a/docs/claude-code-memories/user-runs-e2e-tests.md b/docs/claude-code-memories/user-runs-e2e-tests.md
new file mode 100644
index 0000000..1b206a6
--- /dev/null
+++ b/docs/claude-code-memories/user-runs-e2e-tests.md
@@ -0,0 +1,12 @@
+---
+name: user-runs-e2e-tests
+description: Don't run Playwright/E2E tests yourself — the user runs them and reports results
+metadata:
+ type: feedback
+---
+
+Do not invoke Playwright / E2E runs (`npx playwright test`, `npm run test:e2e`). The user runs E2E themselves and pastes the results back.
+
+**Why:** they drive the dev server and browser environment and prefer to control E2E runs.
+
+**How to apply:** after writing or fixing an `e2e/*.spec.ts`, hand off — ask the user to run it and report. Unit tests (`npm test`), `check:i18n`, `build`, and `php artisan test` are still fine to run yourself. Related: [[user-runs-dev-server]].
diff --git a/docs/claude-code-memories/wordkeep-demo-content-provenance.md b/docs/claude-code-memories/wordkeep-demo-content-provenance.md
new file mode 100644
index 0000000..d67f06a
--- /dev/null
+++ b/docs/claude-code-memories/wordkeep-demo-content-provenance.md
@@ -0,0 +1,16 @@
+---
+name: wordkeep-demo-content-provenance
+description: "Guest-demo content source, licensing, and how the canned data was generated"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 55a6b3eb-e375-406e-9c53-e114f26fd6d9
+---
+
+The unauthenticated guest demo (`/demo`, branch feature/WKP-47) uses 3 public-domain page scans of Thackeray's *Vanity Fair* (Ch. XL→XLI, pdf pages 475–477) from the Wikimedia Commons / Cornell scan (`cu31924013562537`) — **commercial use OK** (Commons files are free for any use; this scan is PD, not Google-digitized).
+
+**Licensing lesson:** the sample `carol.pdf` (and other Google-digitized scans) carry a "make non-commercial use" notice → NOT usable for a commercial product. Safe sources: Project Gutenberg text (strip PG trademark/header), Wikimedia Commons scans, Standard Ebooks (CC0).
+
+The canned OCR + Romanian translation in `wordkeep-client/src/app/demo/demo.fixture.ts` is the **real, unedited** output of the app's own pipeline, captured by running the 3 pages through the live app (doc #19): LM Studio qwen2.5-VL OCR + Gemini translation. Page images at `public/assets/demo/pages/p{1,2,3}.jpg`. Links: 1→2 CONTINUE_ROW (spillover_chars=15 on p2), 2→3 NEW_ROW. Keep output raw — see [[demos-realistic-not-idealistic]].
+
+**Pricing exception (do NOT re-hardcode):** the demo is offline EXCEPT one call — `GET /api/translation/pricing` is a **public** route (moved out of `auth:sanctum` in `routes/api.php`; returns only a static global price list, no user data). `DemoTranslationService` does NOT override `loadPricingRules()`, so the cost dialog fetches live prices and never drifts. There is no `DEMO_PRICING` fixture anymore (a stale hardcoded copy was the bug that motivated this). The e2e/unit "no /api" guards whitelist this one URL.
diff --git a/docs/planning/Jira/.gitignore b/docs/planning/Jira/.gitignore
new file mode 100644
index 0000000..0900437
--- /dev/null
+++ b/docs/planning/Jira/.gitignore
@@ -0,0 +1,3 @@
+# Generated backlog exports from fetch-backlog.ps1 (point-in-time, go stale)
+backlog.md
+backlog.json
diff --git a/docs/planning/Jira/JiraLib.ps1 b/docs/planning/Jira/JiraLib.ps1
new file mode 100644
index 0000000..3c1c701
--- /dev/null
+++ b/docs/planning/Jira/JiraLib.ps1
@@ -0,0 +1,199 @@
+<#
+ JiraLib.ps1 - shared helpers for the WordKeep Jira tooling.
+
+ Dot-source it from a tool: . "$PSScriptRoot\JiraLib.ps1"
+ Then call Connect-Jira once; the other helpers use the connection it stores.
+
+ Why these exist (hard-won notes):
+ - This Jira account can LIST issues via JQL search but cannot GET them by key
+ (/rest/api/3/issue/WKP-1 -> 403/"does not exist"). So everything resolves issues via
+ POST /rest/api/3/search/jql and operates by numeric id.
+ - Issue-link direction (verified against Atlassian's issue-linking model): in a link,
+ inwardIssue = the blocker. Reading an issue, a partner under `inwardIssue` means the
+ issue reads the INWARD description ("is blocked by"); under `outwardIssue` it reads the
+ OUTWARD one ("blocks"). New-BlocksLink self-calibrates by reading back, so it is correct
+ regardless of how an instance maps the fields.
+ - Descriptions are ADF (Atlassian Document Format); non-ASCII is \u-escaped so request
+ bodies stay ASCII.
+#>
+
+function Get-ErrorBody {
+ param($err)
+ if ($err.ErrorDetails -and $err.ErrorDetails.Message) { return $err.ErrorDetails.Message }
+ try {
+ $resp = $err.Exception.Response
+ if ($resp -and $resp.GetResponseStream) {
+ $stream = $resp.GetResponseStream(); $reader = New-Object System.IO.StreamReader($stream)
+ $text = $reader.ReadToEnd(); $reader.Dispose(); if ($text) { return $text }
+ }
+ } catch { }
+ return $err.Exception.Message
+}
+
+# JSON-escape a string, escaping non-ASCII to \uXXXX so the body stays ASCII.
+function ConvertTo-JsonString { param([string] $s)
+ if ($null -eq $s) { return '""' }
+ $sb = New-Object System.Text.StringBuilder
+ [void]$sb.Append('"')
+ foreach ($ch in $s.ToCharArray()) {
+ $code = [int]$ch
+ if ($ch -eq '"') { [void]$sb.Append('\"') }
+ elseif ($ch -eq '\') { [void]$sb.Append('\\') }
+ elseif ($code -eq 8) { [void]$sb.Append('\b') }
+ elseif ($code -eq 9) { [void]$sb.Append('\t') }
+ elseif ($code -eq 10) { [void]$sb.Append('\n') }
+ elseif ($code -eq 12) { [void]$sb.Append('\f') }
+ elseif ($code -eq 13) { [void]$sb.Append('\r') }
+ elseif ($code -lt 32 -or $code -gt 126) { [void]$sb.Append(('\u{0:x4}' -f $code)) }
+ else { [void]$sb.Append($ch) }
+ }
+ [void]$sb.Append('"')
+ return $sb.ToString()
+}
+
+function ConvertTo-LabelsJson { param([string[]] $Labels)
+ if (-not $Labels -or @($Labels).Count -eq 0) { return '[]' }
+ return '[' + ((@($Labels) | ForEach-Object { ConvertTo-JsonString $_ }) -join ',') + ']'
+}
+
+# --- connection (token: -ApiToken, then $env:JIRA_API_TOKEN, then prompt) ---
+function Connect-Jira {
+ param([Parameter(Mandatory)] [string] $SiteUrl, [Parameter(Mandatory)] [string] $Email, [string] $ApiToken)
+ if (-not $ApiToken -and $env:JIRA_API_TOKEN) { $ApiToken = $env:JIRA_API_TOKEN; Write-Host "Using token from `$env:JIRA_API_TOKEN" -ForegroundColor DarkGray }
+ if (-not $ApiToken) { $ApiToken = (Read-Host -Prompt "Jira API token (visible - paste then Enter)").Trim() }
+ if ([string]::IsNullOrWhiteSpace($ApiToken)) { throw "No API token provided." }
+ $global:JiraSite = $SiteUrl.TrimEnd('/')
+ $pair = "{0}:{1}" -f $Email, $ApiToken
+ $basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair))
+ $global:JiraHeaders = @{ Authorization = "Basic $basic"; Accept = 'application/json' }
+ try {
+ $me = Invoke-RestMethod -Method Get -Uri "$global:JiraSite/rest/api/3/myself" -Headers $global:JiraHeaders
+ Write-Host ("Authenticated as {0} <{1}>" -f $me.displayName, $me.emailAddress) -ForegroundColor Green
+ return $me
+ } catch { throw ("Authentication failed (check -Email and API token). {0}" -f (Get-ErrorBody $_)) }
+}
+
+function Invoke-Jira {
+ param([string] $Method, [string] $Path, $Body)
+ $uri = "$global:JiraSite$Path"
+ if ($Body) {
+ $json = ($Body | ConvertTo-Json -Depth 40 -Compress)
+ return Invoke-RestMethod -Method $Method -Uri $uri -Headers $global:JiraHeaders -ContentType 'application/json' -Body $json
+ }
+ return Invoke-RestMethod -Method $Method -Uri $uri -Headers $global:JiraHeaders
+}
+
+# Run a JQL search (POST), returning all matching issues. $Fields is an array of field ids.
+function Get-JiraIssues {
+ param([string] $Jql, [string[]] $Fields = @('summary'))
+ $all = @(); $token = $null
+ do {
+ $body = @{ jql = $Jql; fields = $Fields; maxResults = 100 }
+ if ($token) { $body.nextPageToken = $token }
+ try { $resp = Invoke-Jira POST '/rest/api/3/search/jql' $body }
+ catch { throw ("search failed for jql [{0}]: {1}" -f $Jql, (Get-ErrorBody $_)) }
+ if ($resp.issues) { $all += $resp.issues }
+ $token = $resp.nextPageToken
+ } while ($token)
+ return $all
+}
+
+# Build @{ ByKey; BySummary } lookup maps from a set of issues.
+function Get-JiraIndex {
+ param([object[]] $Issues)
+ $byKey = @{}; $bySum = @{}
+ foreach ($i in $Issues) { $byKey[$i.key] = $i; if ($i.fields.summary) { $bySum[($i.fields.summary).Trim()] = $i } }
+ return [pscustomobject]@{ ByKey = $byKey; BySummary = $bySum }
+}
+
+# Resolve a token (a real key like WKP-90, or an exact summary) to an issue via an index.
+function Resolve-JiraIssue {
+ param($Index, [string] $Token)
+ if ($Index.ByKey.ContainsKey($Token)) { return $Index.ByKey[$Token] }
+ if ($Index.BySummary.ContainsKey($Token.Trim())) { return $Index.BySummary[$Token.Trim()] }
+ return $null
+}
+
+# --- ADF builders ---
+# A doc = optional lead paragraph + optional "Acceptance criteria" heading + task checklist.
+function Build-AdfDoc { param([string] $Lead, [string[]] $AC)
+ $nodes = @()
+ if ($Lead) { $nodes += '{"type":"paragraph","content":[{"type":"text","text":' + (ConvertTo-JsonString $Lead) + '}]}' }
+ $items = @($AC | Where-Object { $_ -and $_.Trim() })
+ if ($items.Count -gt 0) {
+ $nodes += '{"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Acceptance criteria"}]}'
+ $tis = @()
+ for ($k = 0; $k -lt $items.Count; $k++) {
+ $tis += '{"type":"taskItem","attrs":{"localId":"ac-' + ($k + 1) + '","state":"TODO"},"content":[{"type":"text","text":' + (ConvertTo-JsonString $items[$k]) + '}]}'
+ }
+ $nodes += '{"type":"taskList","attrs":{"localId":"ac-list"},"content":[' + ($tis -join ',') + ']}'
+ }
+ if ($nodes.Count -eq 0) { $nodes += '{"type":"paragraph","content":[]}' }
+ return '{"type":"doc","version":1,"content":[' + ($nodes -join ',') + ']}'
+}
+
+# Split "lead. AC: a; b; c." -> @{ Lead; Items[] }
+function Split-AcDescription { param([string] $Desc)
+ $lead = $Desc; $items = @()
+ $idx = $Desc.IndexOf('AC:')
+ if ($idx -ge 0) {
+ $lead = $Desc.Substring(0, $idx).Trim()
+ $ac = $Desc.Substring($idx + 3).Trim().TrimEnd('.')
+ $items = @($ac -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+ }
+ return [pscustomobject]@{ Lead = $lead.Trim(); Items = $items }
+}
+
+# Flatten an ADF node to plain text (used to check whether a note marker is already present).
+function Get-AdfText { param($Node)
+ if ($null -eq $Node) { return '' }
+ if ($Node -is [string]) { return $Node }
+ $s = ''
+ if ($null -ne $Node.text) { $s += [string]$Node.text }
+ if ($null -ne $Node.content) { foreach ($c in $Node.content) { $s += (Get-AdfText $c) } }
+ return $s
+}
+
+# Existing ADF doc (or $null) + a note -> a new ADF doc object with a rule + note paragraph appended.
+function Add-AdfNote { param($Existing, [string] $NoteText)
+ $content = @()
+ if ($Existing -and $Existing.content) { foreach ($n in $Existing.content) { $content += $n } }
+ $content += @{ type = 'rule' }
+ $content += @{ type = 'paragraph'; content = @(@{ type = 'text'; text = $NoteText }) }
+ return @{ type = 'doc'; version = 1; content = $content }
+}
+
+function New-AdfComment { param([string] $Text)
+ return @{ body = @{ type = 'doc'; version = 1; content = @(@{ type = 'paragraph'; content = @(@{ type = 'text'; text = $Text }) }) } }
+}
+
+# --- issue links ---
+# From $Issue's view, a link of $TypeName to $OtherKey. Side='in' -> partner is inwardIssue
+# (current issue reads the inward desc; for Blocks: current "is blocked by" partner).
+function Get-PartnerSide {
+ param($Issue, [string] $OtherKey, [string] $TypeName)
+ foreach ($l in @($Issue.fields.issuelinks)) {
+ if ($l.type.name -ne $TypeName) { continue }
+ if ($l.inwardIssue -and $l.inwardIssue.key -eq $OtherKey) { return @{ Id = $l.id; Side = 'in' } }
+ if ($l.outwardIssue -and $l.outwardIssue.key -eq $OtherKey) { return @{ Id = $l.id; Side = 'out' } }
+ }
+ return $null
+}
+
+function New-IssueLinkById { param([string] $TypeName, [string] $InId, [string] $OutId)
+ Invoke-Jira POST '/rest/api/3/issueLink' @{ type = @{ name = $TypeName }; inwardIssue = @{ id = $InId }; outwardIssue = @{ id = $OutId } } | Out-Null }
+function Remove-IssueLink { param([string] $Id) Invoke-Jira DELETE "/rest/api/3/issueLink/$Id" | Out-Null }
+
+# Re-fetch a single issue's issuelinks fresh (for read-back).
+function Get-IssueLinksFresh { param([string] $Key)
+ $i = Get-JiraIssues "key = $Key" @('summary', 'issuelinks'); return ($i | Select-Object -First 1)
+}
+
+# Transition an issue (by id) to a target status name. Returns $true if moved.
+function Move-IssueById { param([string] $Id, [string] $ToStatus)
+ $tr = Invoke-Jira GET "/rest/api/3/issue/$Id/transitions"
+ $m = $tr.transitions | Where-Object { $_.to.name -eq $ToStatus -or $_.name -eq $ToStatus } | Select-Object -First 1
+ if (-not $m) { Write-Host (" ! no transition to '{0}' (have: {1})" -f $ToStatus, (($tr.transitions.name) -join ', ')) -ForegroundColor Yellow; return $false }
+ Invoke-Jira POST "/rest/api/3/issue/$Id/transitions" @{ transition = @{ id = $m.id } } | Out-Null
+ return $true
+}
diff --git a/docs/planning/Jira/README.md b/docs/planning/Jira/README.md
new file mode 100644
index 0000000..7147694
--- /dev/null
+++ b/docs/planning/Jira/README.md
@@ -0,0 +1,77 @@
+# WordKeep Jira tooling
+
+Small, reusable PowerShell tools for driving this project's Jira (REST API v3) from the
+command line: fetch the backlog, import issues, link them, and apply batched edits. The
+*data* for each action lives in a CSV/JSON file you pass in — the scripts are generic.
+
+## Setup
+
+```powershell
+$env:JIRA_API_TOKEN = "" # create at https://id.atlassian.com -> Security -> API tokens
+```
+
+Every tool takes `-SiteUrl` and `-Email`, resolves the token from `-ApiToken`, then
+`$env:JIRA_API_TOKEN`, then a prompt, and supports **`-WhatIf`** (dry run — always run it first).
+
+```powershell
+$site = "https://zany-fox.atlassian.net"; $me = "david_emilg@yahoo.com"
+```
+
+## Tools
+
+| Script | Does | Data file |
+| ------ | ---- | --------- |
+| `fetch-backlog.ps1` | Export To-Do/Backlog issues to a Markdown list | (none) |
+| `import-issues.ps1` | Create Epics + children with acceptance-criteria checklists | `-CsvPath` (see `examples/tickets-*.csv`) |
+| `link-issues.ps1` | Create `Blocks` / `Relates` links | `-EdgesCsv` (see `examples/links-*.csv`) |
+| `update-issues.ps1` | Retitle / append note / transition / comment / duplicate-link | `-ChangesJson` (see `examples/updates-*.json`) |
+
+`JiraLib.ps1` holds the shared mechanics (auth, search, ADF, links, transitions); the tools
+dot-source it. `examples/` holds the real datasets we've already applied, as worked references.
+
+### Examples
+
+```powershell
+# 1. Snapshot the backlog
+./fetch-backlog.ps1 -SiteUrl $site -Email $me # -> backlog.md
+
+# 2. Import a feature's tickets
+./import-issues.ps1 -SiteUrl $site -Email $me -CsvPath examples/tickets-export-images.csv -WhatIf
+./import-issues.ps1 -SiteUrl $site -Email $me -CsvPath examples/tickets-export-images.csv
+
+# 3. Link them (From "is blocked by" To; From/To = a key OR an exact summary)
+./link-issues.ps1 -SiteUrl $site -Email $me -EdgesCsv examples/links-feature-deps.csv -WhatIf
+./link-issues.ps1 -SiteUrl $site -Email $me -EdgesCsv examples/links-feature-deps.csv
+
+# 4. Apply a batch of edits
+./update-issues.ps1 -SiteUrl $site -Email $me -ChangesJson examples/updates-audit-2026-06-29.json -WhatIf
+```
+
+## Data formats
+
+**Import CSV** — `Issue Type, Issue Key, Summary, Parent, Labels, Description`. `Issue Key` is a
+*local* placeholder (e.g. `EXP-0`) only used to wire a child's `Parent` to its Epic; Jira assigns
+the real key. An `AC:` marker in `Description` becomes a checklist: `lead. AC: item; item; item`.
+
+**Links CSV** — `LinkType, From, To`. `LinkType` is `Blocks` or `Relates`. For `Blocks`, the row
+reads "**From** is blocked by **To**" (From depends on To). `From`/`To` are a real key (`WKP-90`)
+or an exact summary — you can mix.
+
+**Updates JSON** — array of `{ key, newSummary?, note?, transition?, comment?, duplicateOf? }`.
+`key` is a real key or exact summary. `note` is appended (skipped if already present). `comment`
+is posted only when a `transition` is actually performed.
+
+## Gotchas (why the code looks the way it does)
+
+- **This account can search but not GET issues by key** (`/rest/api/3/issue/WKP-1` returns
+ 403 / "does not exist"). So everything resolves issues via `POST /rest/api/3/search/jql` and
+ operates by numeric **id**. Use `-WhatIf`; if a row can't be resolved it's reported by name.
+- **Link direction self-calibrates.** Atlassian's model: in a link, `inwardIssue` = the blocker.
+ `link-issues.ps1` creates a Blocks link, reads it back from the dependent's side, and flips if
+ it doesn't read "is blocked by" — correct on any instance. (Re-running fixes inverted links.)
+- **Idempotent.** Imports skip existing summaries; links skip/repair; notes skip if already in the
+ description; transitions skip if already in the target status. Safe to re-run.
+- **Descriptions are ADF** with non-ASCII `\u`-escaped (keeps request bodies ASCII — also why the
+ scripts themselves are ASCII-only, to survive shells that read files as ANSI).
+
+Generated `backlog.md` / `backlog.json` are git-ignored (see `.gitignore`).
diff --git a/docs/planning/Jira/examples/links-backlog.csv b/docs/planning/Jira/examples/links-backlog.csv
new file mode 100644
index 0000000..95b4a87
--- /dev/null
+++ b/docs/planning/Jira/examples/links-backlog.csv
@@ -0,0 +1,5 @@
+LinkType,From,To
+Blocks,WKP-84,WKP-83
+Blocks,WKP-50,WKP-49
+Relates,WKP-79,WKP-77
+Relates,WKP-71,WKP-69
diff --git a/docs/planning/Jira/examples/links-feature-deps.csv b/docs/planning/Jira/examples/links-feature-deps.csv
new file mode 100644
index 0000000..3a84b33
--- /dev/null
+++ b/docs/planning/Jira/examples/links-feature-deps.csv
@@ -0,0 +1,16 @@
+LinkType,From,To
+Blocks,"Add format-parameterized export endpoint","Add Pandoc to the stack and a DocxExporter service"
+Blocks,"Add format-parameterized export endpoint","Build server-side BookMarkdownAssembler"
+Blocks,"Add format-parameterized export endpoint","Author A6/A5/A4 reference templates + registry"
+Blocks,"Client export menu (format + template + language)","Add format-parameterized export endpoint"
+Blocks,"Migrate existing Markdown export to the endpoint","Add format-parameterized export endpoint"
+Blocks,"E2E test for templated export","Client export menu (format + template + language)"
+Blocks,"E2E test for templated export","Migrate existing Markdown export to the endpoint"
+Blocks,"Interactive page-image crop endpoint","DB: document_page_images table + model"
+Blocks,"Poppler auto-extract of embedded images","DB: document_page_images table + model"
+Blocks,"Image list / serve / manage endpoints","DB: document_page_images table + model"
+Blocks,"Client per-page image gallery + crop tool","Interactive page-image crop endpoint"
+Blocks,"Client per-page image gallery + crop tool","Image list / serve / manage endpoints"
+Blocks,"Embed included images into exports","DB: document_page_images table + model"
+Blocks,"Embed included images into exports","Add format-parameterized export endpoint"
+Blocks,"Tests for extraction, crop and export-with-images","Embed included images into exports"
diff --git a/docs/planning/Jira/examples/tickets-export-images.csv b/docs/planning/Jira/examples/tickets-export-images.csv
new file mode 100644
index 0000000..5a6797c
--- /dev/null
+++ b/docs/planning/Jira/examples/tickets-export-images.csv
@@ -0,0 +1,17 @@
+Issue Type,Issue Key,Summary,Parent,Labels,Description
+Epic,EXP-0,"Word (.docx) export with print-ready templates",,"export feature","Export a digitized book to Word (.docx) using print-ready templates (A6/A5/A4). The server assembles Markdown once and Pandoc converts it with a per-format reference document, so footnotes become real Word footnotes and page size/margins/styles come from the template. All exports (md and docx) move behind one format-parameterized endpoint."
+Task,EXP-1,"Add Pandoc to the stack and a DocxExporter service",EXP-0,"export","Install Pandoc and wrap it in a DocxExporter service. AC: pandoc installed in the Docker image; pandoc available in CI; dev-setup docs cover the Windows/Herd install (choco or scoop); DocxExporter runs pandoc via Process in array form and returns the generated .docx path; a clear error is raised if pandoc is missing."
+Task,EXP-2,"Build server-side BookMarkdownAssembler",EXP-0,"export","Port the client assembleMarkdown logic to PHP as the single source of truth for exports. AC: filters non-ignored pages and joins them per CONTINUE_ROW/NEW_ROW; appends footnotes as [^N] reference definitions; supports original text or a target language (page and footnote translations); unit tests assert parity with the client output for the same input."
+Task,EXP-3,"Add format-parameterized export endpoint",EXP-0,"export","Expose GET /api/documents/{id}/export with format, template and language params. AC: a Form Request validates format (md|docx), template and language; non-owners get 404; returns a BinaryFileResponse with the correct mime and filename and deletes the temp file after send; the method has PHPDoc for Scramble."
+Task,EXP-4,"Author A6/A5/A4 reference templates + registry",EXP-0,"export","Create reference .docx templates and a config registry. AC: a6/a5/a4 reference docs define page size, margins and body/heading/footnote styles; config/export.php maps each slug to its file and a label; an invalid template slug is rejected by validation; A6 output opens in Word with the correct page size and margins."
+Task,EXP-5,"Client export menu (format + template + language)",EXP-0,"export","Add an export control in the book-view reader. AC: the user can pick format (md or docx), template and language; the control calls the export endpoint and downloads the returned file; it is disabled while loading; it reuses the reader's selected language."
+Task,EXP-6,"Migrate existing Markdown export to the endpoint",EXP-0,"export","Point the existing client .md exports at the new endpoint. AC: the three client call sites (book-view reader, document detail, book-view) download via the endpoint instead of assembling locally; the client assembler is kept only for the live on-screen reader; affected specs are updated; the .md output is unchanged for users."
+Task,EXP-7,"E2E test for templated export",EXP-0,"export","End-to-end coverage of export. AC: an E2E test exports a seeded document to docx and asserts a valid file with real Word footnotes; the A6 template yields the correct page size and margins; a Romanian export uses the translated text; the .md export via the endpoint still works."
+Epic,IMG-0,"Extract & embed PDF illustrations into books",,"images feature","Bring illustrations from the source PDF into the book. Users can interactively crop a region of a page image (handles illustrations inside full-page scans) and auto-extract embedded raster images from digital PDFs with Poppler. Included images are embedded into both the Markdown and Word exports."
+Task,IMG-1,"DB: document_page_images table + model",IMG-0,"images","Add storage for book images. AC: a SQLite-compatible migration (Schema::create + Blueprint, no raw ALTER) creates document_page_images with id, document_id, page_number, source (crop|embedded), storage_path, mime, width, height, bbox (nullable json), included (bool), position (int) and timestamps; a DocumentPageImage model with relations to the document and page; a foreign key and an index on document_id + page_number."
+Task,IMG-2,"Interactive page-image crop endpoint",IMG-0,"images","Crop a region of a page image at print quality. AC: POST /documents/{id}/pages/{page}/images/crop accepts a bbox; the server re-renders the page at 300 DPI with Ghostscript and crops it with GD; the result is stored on the documents disk under the document's images folder; the bbox is validated to lie within the page bounds; ownership is enforced; the new image resource is returned."
+Task,IMG-3,"Poppler auto-extract of embedded images",IMG-0,"images","Auto-pull embedded raster illustrations from digital PDFs. AC: poppler-utils is added to Docker, CI and dev; an ImageExtractor service runs pdfimages per page and filters noise under 5KB; extracted candidates are stored with source=embedded; a bulk job processes a whole document with progress; pages flagged by pageHasEmbeddedImages are prioritized."
+Task,IMG-4,"Image list / serve / manage endpoints",IMG-0,"images","Manage book images. AC: list endpoints (all and per page) return an API Resource; the binary endpoint is auth-gated and sends Cache-Control private; PATCH toggles included and reorders; DELETE removes the file and row; every endpoint is ownership-scoped and returns 404 on mismatch."
+Task,IMG-5,"Client per-page image gallery + crop tool",IMG-0,"images","Let users curate images per page. AC: the editor shows extracted and cropped images for each page; a region selector crops from the page image; users can include or exclude and reorder; deletions are reflected; the UI calls the image endpoints."
+Task,IMG-6,"Embed included images into exports",IMG-0,"images","Make exports image-aware. AC: BookMarkdownAssembler injects an image reference for each included image at the right page boundary, ordered by position; the Markdown export contains the references; the Pandoc docx embeds the image files via resolved absolute paths; excluded images are omitted."
+Task,IMG-7,"Tests for extraction, crop and export-with-images",IMG-0,"images","Cover the image pipeline. AC: tests verify a crop bbox produces a stored image, Poppler extraction creates candidates and filters noise, and exports embed included images into both md and docx; ownership and security checks are included."
diff --git a/docs/planning/Jira/examples/updates-audit-2026-06-29.json b/docs/planning/Jira/examples/updates-audit-2026-06-29.json
new file mode 100644
index 0000000..0af40b5
--- /dev/null
+++ b/docs/planning/Jira/examples/updates-audit-2026-06-29.json
@@ -0,0 +1,54 @@
+[
+ {
+ "key": "WKP-56",
+ "transition": "Done",
+ "comment": "Verified implemented by the 2026-06-29 audit: a confirmation modal with cost estimates exists for both bulk and single-page AI translation (ConfirmationService / ConfirmationDialogComponent). Closing."
+ },
+ {
+ "key": "WKP-78",
+ "transition": "Done",
+ "duplicateOf": "WKP-83",
+ "comment": "Duplicate of WKP-83 (restyle emails to the landing-page look). Closing as duplicate; work tracked on WKP-83."
+ },
+ {
+ "key": "WKP-83",
+ "note": "Audit (2026-06-29): the email templates are already branded (custom verify/reset Blade views), but they still follow the OLD WordKeep visual style. Remaining work = restyle them to match the current landing-page design. Duplicate WKP-78 closed in favour of this ticket."
+ },
+ {
+ "key": "WKP-51",
+ "note": "Audit (2026-06-29): scope is scalability RESEARCH, not 'do indexes exist'. Basic indexes are present (documents.status/created_at, sessions.user_id/last_activity, work_statuses composites). Deliverable = model how the current schema/queries behave at hundreds-to-thousands of users: N+1 queries, large-table growth (document_pages, translations), missing composite indexes, and per-user isolation cost; then recommend changes."
+ },
+ {
+ "key": "WKP-77",
+ "note": "Audit (2026-06-29): the security review was delivered (security-review-2026-05-08.md). Foundation is solid (Sanctum + 2FA, consistent ownership checks, no IDOR/SQLi). Already done: throttling on login / forgot-password / 2FA-challenge (5/min). Outstanding Critical/High to act on: email-change token takeover (C1); throttle register/verify-email/resend/reset (C2,H2); per-challenge 2FA attempt counter (H1); revoke Sanctum tokens on password change/reset (H3); Ghostscript -dSAFER on page-count (H4); nginx security headers (H7); Sanctum token expiry + localStorage exposure (H5,M11). Recommend splitting the remediation plan into sub-tasks. Also rotate the Groq + Gemini API keys (finding I6)."
+ },
+ {
+ "key": "WKP-65",
+ "note": "Audit (2026-06-29): auto-translation of footnotes is already implemented (translateFootnote / retranslateFootnote). Remaining = a manual edit/override UI so users can type or correct footnote translations."
+ },
+ {
+ "key": "WKP-61",
+ "newSummary": "Add larastan and laravel-rector to WordKeep (Pint already present)",
+ "note": "Audit (2026-06-29): laravel/pint v1.24 is already in composer.json require-dev. Remaining = add larastan/larastan and a Rector setup (rector/rector + driftingly/rector-laravel)."
+ },
+ {
+ "key": "WKP-49",
+ "note": "Audit (2026-06-29): partial. 10/15 API controllers return API Resources and 7 use both FormRequest + Resource. Remaining controllers still on inline validation / manual responses: OcrController, ProfileController, AuthController, PageController, BulkOcrController."
+ },
+ {
+ "key": "WKP-58",
+ "note": "Audit (2026-06-29): Playwright already supports headed mode (HEADED=1) and has UI mode available. Clarify what 'better / interactive' should add beyond Playwright's built-in UI mode before scoping."
+ },
+ {
+ "key": "WKP-48",
+ "note": "Audit (2026-06-29): the extraction-reader already has a page-number input for navigation. This ticket = replicate it in the page-linking reader, which currently offers only prev / next / first-unlinked / next-unlinked buttons."
+ },
+ {
+ "key": "WKP-73",
+ "note": "Audit (2026-06-29): book-view has text/font-size zoom only. This ticket = zoom into the page IMAGE in the extraction / page-linking readers (no image zoom exists today)."
+ },
+ {
+ "key": "WKP-45",
+ "note": "Audit (2026-06-29): two extraction methods already exist separately - vision OCR (VisionClient) and embedded-text extraction (PdfTextExtractor), chosen via extraction_method. Idea = FUSE them: use the embedded PDF text layer to seed / validate / improve the vision OCR result, rather than treating them as either/or."
+ }
+]
diff --git a/docs/planning/Jira/fetch-backlog.ps1 b/docs/planning/Jira/fetch-backlog.ps1
new file mode 100644
index 0000000..cbdb6c1
--- /dev/null
+++ b/docs/planning/Jira/fetch-backlog.ps1
@@ -0,0 +1,237 @@
+<#
+ WordKeep <- Jira backlog exporter (REST API v3)
+
+ Fetches every issue in the project's TO DO / Backlog (statusCategory "To Do" by
+ default) and writes a reviewable list to a markdown file (and prints a summary table).
+ Use it to harvest future-feature ideas - e.g. to draft the booklet's "Future ideas" page.
+
+ This is the read-only mirror of the tapestry import script: same auth + paginated
+ /rest/api/3/search/jql, but it PULLS issues and flattens their ADF descriptions to text
+ instead of pushing. Nothing is created or modified in Jira.
+
+ What it collects per issue: key, type, status, priority, labels, parent, created/updated,
+ and the description (ADF flattened to plain text / light markdown).
+
+ Usage:
+ ./fetch-backlog.ps1 -SiteUrl "https://YOURSITE.atlassian.net" -Email "you@example.com"
+ ./fetch-backlog.ps1 -SiteUrl "..." -Email "..." -Statuses "Backlog","To Do","Selected for Development"
+ ./fetch-backlog.ps1 -SiteUrl "..." -Email "..." -Jql 'project = WKP AND status = Backlog ORDER BY Rank ASC'
+
+ Token resolution: -ApiToken, then $env:JIRA_API_TOKEN, then a visible prompt.
+ Create a token at https://id.atlassian.com -> Security -> API tokens.
+#>
+
+param(
+ [Parameter(Mandatory = $true)] [string] $SiteUrl,
+ [Parameter(Mandatory = $true)] [string] $Email,
+ [string] $ProjectKey = 'WKP',
+ [string] $ApiToken,
+ [string[]] $Statuses, # explicit status names; overrides the statusCategory default
+ [string] $Jql, # full JQL override (ignores -ProjectKey/-Statuses)
+ [string] $OutFile, # markdown output (default: ./backlog.md next to this script)
+ [switch] $NoDescription, # omit the per-issue description bodies
+ [switch] $AsJson # also dump the raw issues to .json
+)
+
+$ErrorActionPreference = 'Stop'
+if ($SiteUrl) { $SiteUrl = $SiteUrl.TrimEnd('/') }
+if (-not $OutFile) { $OutFile = Join-Path $PSScriptRoot 'backlog.md' }
+
+# ============================ helpers ============================
+
+function Get-ErrorBody {
+ param($err)
+ if ($err.ErrorDetails -and $err.ErrorDetails.Message) { return $err.ErrorDetails.Message }
+ try {
+ $resp = $err.Exception.Response
+ if ($resp -and $resp.GetResponseStream) {
+ $stream = $resp.GetResponseStream()
+ $reader = New-Object System.IO.StreamReader($stream)
+ $text = $reader.ReadToEnd()
+ $reader.Dispose()
+ if ($text) { return $text }
+ }
+ } catch { }
+ return $err.Exception.Message
+}
+
+# Flatten an ADF (Atlassian Document Format) description to readable plain text / light
+# markdown. Falls back to the raw value if Jira returned a plain string (v2 fields).
+function Convert-AdfToText {
+ param($Node, [int] $Depth = 0)
+ if ($null -eq $Node) { return '' }
+ if ($Node -is [string]) { return $Node }
+
+ switch ($Node.type) {
+ 'text' { return [string]$Node.text }
+ 'hardBreak' { return "`n" }
+ 'rule' { return "`n---`n" }
+ 'mention' { if ($Node.attrs) { return '@' + $Node.attrs.text } else { return '' } }
+ 'emoji' { if ($Node.attrs) { return [string]$Node.attrs.text } else { return '' } }
+ default { }
+ }
+
+ # gather children
+ $inner = ''
+ if ($null -ne $Node.content) {
+ foreach ($c in $Node.content) { $inner += (Convert-AdfToText -Node $c -Depth ($Depth + 1)) }
+ }
+
+ switch ($Node.type) {
+ 'paragraph' { return ($inner.TrimEnd() + "`n`n") }
+ 'heading' {
+ $lvl = 1; if ($Node.attrs -and $Node.attrs.level) { $lvl = [int]$Node.attrs.level }
+ return ('#' * ([Math]::Min($lvl + 2, 6)) + ' ' + $inner.Trim() + "`n`n")
+ }
+ 'blockquote' { return (($inner.TrimEnd() -split "`n" | ForEach-Object { '> ' + $_ }) -join "`n") + "`n`n" }
+ 'codeBlock' { return ('```' + "`n" + $inner.TrimEnd() + "`n" + '```' + "`n`n") }
+ 'bulletList' { return $inner }
+ 'orderedList' { return $inner }
+ 'listItem' { return (' ' * $Depth) + '- ' + $inner.Trim() + "`n" }
+ 'taskList' { return $inner }
+ 'taskItem' {
+ $box = '[ ]'; if ($Node.attrs -and $Node.attrs.state -eq 'DONE') { $box = '[x]' }
+ return (' ' * $Depth) + "- $box " + $inner.Trim() + "`n"
+ }
+ 'doc' { return $inner }
+ default { return $inner }
+ }
+}
+
+function Format-Description {
+ param($Desc)
+ if ($null -eq $Desc) { return '' }
+ $t = (Convert-AdfToText -Node $Desc).Trim()
+ # collapse 3+ blank lines to one
+ $t = [regex]::Replace($t, "(`r?`n){3,}", "`n`n")
+ return $t
+}
+
+# ============================ auth ============================
+
+if (-not $ApiToken -and $env:JIRA_API_TOKEN) {
+ $ApiToken = $env:JIRA_API_TOKEN
+ Write-Host "Using token from `$env:JIRA_API_TOKEN" -ForegroundColor DarkGray
+}
+if (-not $ApiToken) {
+ $ApiToken = (Read-Host -Prompt "Jira API token (visible - paste then Enter)").Trim()
+}
+if ([string]::IsNullOrWhiteSpace($ApiToken)) { throw "No API token provided." }
+if ($ApiToken.Length -lt 20) {
+ Write-Host " WARNING: that looks too short for a Jira API token - did the paste truncate?" -ForegroundColor Yellow
+}
+
+$pair = "{0}:{1}" -f $Email, $ApiToken
+$basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair))
+$headers = @{ Authorization = "Basic $basic"; Accept = 'application/json' }
+
+try {
+ $me = Invoke-RestMethod -Method Get -Uri "$SiteUrl/rest/api/3/myself" -Headers $headers
+ Write-Host ("Authenticated as {0} <{1}>" -f $me.displayName, $me.emailAddress) -ForegroundColor Green
+} catch {
+ throw ("Authentication failed (check -Email and API token). {0}" -f (Get-ErrorBody $_))
+}
+
+# ============================ build JQL ============================
+
+if (-not $Jql) {
+ if ($Statuses -and @($Statuses).Count -gt 0) {
+ $inList = (@($Statuses) | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ', '
+ $Jql = "project = $ProjectKey AND status in ($inList) ORDER BY priority DESC, created ASC"
+ } else {
+ # statusCategory "To Do" covers Backlog, To Do, Selected for Development, etc.
+ $Jql = "project = $ProjectKey AND statusCategory = `"To Do`" ORDER BY priority DESC, created ASC"
+ }
+}
+Write-Host ("JQL: {0}" -f $Jql) -ForegroundColor Cyan
+
+# ============================ fetch (paginated) ============================
+
+$fields = 'summary,description,status,issuetype,priority,labels,parent,created,updated'
+$issues = @()
+$token = $null
+do {
+ $u = "$SiteUrl/rest/api/3/search/jql?jql=" + [uri]::EscapeDataString($Jql) +
+ "&fields=$fields&maxResults=100"
+ if ($token) { $u += "&nextPageToken=" + [uri]::EscapeDataString($token) }
+ try {
+ $resp = Invoke-RestMethod -Method Get -Uri $u -Headers $headers
+ } catch {
+ throw ("Search failed: {0}" -f (Get-ErrorBody $_))
+ }
+ if ($resp.issues) { $issues += $resp.issues }
+ $token = $resp.nextPageToken
+ Write-Host (" fetched {0} so far..." -f $issues.Count) -ForegroundColor DarkGray
+} while ($token)
+
+Write-Host ("{0} issue(s) matched." -f $issues.Count) -ForegroundColor Green
+if ($issues.Count -eq 0) {
+ Write-Host "Nothing to write. Check the project key / statuses / your board's status names." -ForegroundColor Yellow
+ return
+}
+
+# shape into flat records
+$records = foreach ($iss in $issues) {
+ [pscustomobject]@{
+ Key = $iss.key
+ Type = $iss.fields.issuetype.name
+ Status = $iss.fields.status.name
+ Priority = if ($iss.fields.priority) { $iss.fields.priority.name } else { '' }
+ Parent = if ($iss.fields.parent) { $iss.fields.parent.key } else { '' }
+ Labels = @($iss.fields.labels) -join ', '
+ Summary = ($iss.fields.summary).Trim()
+ Updated = if ($iss.fields.updated) { ([datetime]$iss.fields.updated).ToString('yyyy-MM-dd') } else { '' }
+ Description = Format-Description $iss.fields.description
+ }
+}
+
+# ============================ console summary ============================
+
+Write-Host ""
+$records | Select-Object Key, Type, Status, Priority, Summary | Format-Table -AutoSize -Wrap
+
+# ============================ markdown out ============================
+
+$sb = New-Object System.Text.StringBuilder
+[void]$sb.AppendLine("# WordKeep - Backlog / To-Do ideas")
+[void]$sb.AppendLine()
+[void]$sb.AppendLine("Project ``$ProjectKey`` - $($records.Count) issue(s) - exported $($me.displayName)'s view")
+[void]$sb.AppendLine()
+[void]$sb.AppendLine("> JQL: ``$Jql``")
+[void]$sb.AppendLine()
+[void]$sb.AppendLine("| Key | Type | Status | Priority | Summary |")
+[void]$sb.AppendLine("| --- | ---- | ------ | -------- | ------- |")
+foreach ($r in $records) {
+ $sum = $r.Summary -replace '\|', '\|'
+ [void]$sb.AppendLine("| $($r.Key) | $($r.Type) | $($r.Status) | $($r.Priority) | $sum |")
+}
+[void]$sb.AppendLine()
+
+if (-not $NoDescription) {
+ [void]$sb.AppendLine("---")
+ [void]$sb.AppendLine()
+ [void]$sb.AppendLine("## Details")
+ [void]$sb.AppendLine()
+ foreach ($r in $records) {
+ [void]$sb.AppendLine("### $($r.Key) - $($r.Summary)")
+ $meta = "**Type:** $($r.Type) - **Status:** $($r.Status)"
+ if ($r.Priority) { $meta += " - **Priority:** $($r.Priority)" }
+ if ($r.Parent) { $meta += " - **Parent:** $($r.Parent)" }
+ if ($r.Labels) { $meta += " - **Labels:** $($r.Labels)" }
+ [void]$sb.AppendLine($meta)
+ [void]$sb.AppendLine()
+ if ($r.Description) { [void]$sb.AppendLine($r.Description) } else { [void]$sb.AppendLine("_(no description)_") }
+ [void]$sb.AppendLine()
+ }
+}
+
+$dir = Split-Path -Parent $OutFile
+if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
+Set-Content -LiteralPath $OutFile -Value $sb.ToString() -Encoding UTF8
+Write-Host ("Wrote {0}" -f $OutFile) -ForegroundColor Green
+
+if ($AsJson) {
+ $jsonPath = [System.IO.Path]::ChangeExtension($OutFile, 'json')
+ $records | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
+ Write-Host ("Wrote {0}" -f $jsonPath) -ForegroundColor Green
+}
diff --git a/docs/planning/Jira/import-issues.ps1 b/docs/planning/Jira/import-issues.ps1
new file mode 100644
index 0000000..e2de849
--- /dev/null
+++ b/docs/planning/Jira/import-issues.ps1
@@ -0,0 +1,75 @@
+<#
+ import-issues.ps1 - create Jira issues (Epics + children) from a CSV.
+
+ CSV columns: Issue Type, Issue Key, Summary, Parent, Labels, Description
+ - Issue Type : Epic | Task | Story | Spike | Bug
+ - Issue Key : a LOCAL key (e.g. EXP-0) used only to wire children to their parent; Jira
+ assigns the real key. Children set Parent = the parent's local key.
+ - Labels : space/comma separated
+ - Description: free text; an "AC:" marker turns "a; b; c" into an acceptance-criteria checklist.
+
+ Idempotent by SUMMARY (skips issues whose summary already exists in the project). -WhatIf dry-runs.
+
+ Usage:
+ ./import-issues.ps1 -SiteUrl https://your.atlassian.net -Email you@x.com -CsvPath examples/tickets-export-images.csv -WhatIf
+#>
+param(
+ [Parameter(Mandatory)] [string] $SiteUrl,
+ [Parameter(Mandatory)] [string] $Email,
+ [Parameter(Mandatory)] [string] $CsvPath,
+ [string] $ProjectKey = 'WKP',
+ [string] $ApiToken,
+ [switch] $WhatIf
+)
+$ErrorActionPreference = 'Stop'
+. "$PSScriptRoot\JiraLib.ps1"
+
+if (-not (Test-Path $CsvPath)) { throw "CSV not found: $CsvPath" }
+$rows = Import-Csv -LiteralPath $CsvPath
+Write-Host ("Loaded {0} rows from {1}" -f $rows.Count, $CsvPath) -ForegroundColor Cyan
+
+Connect-Jira -SiteUrl $SiteUrl -Email $Email -ApiToken $ApiToken | Out-Null
+if ($WhatIf) { Write-Host "`n*** DRY RUN (-WhatIf): nothing will be created ***`n" -ForegroundColor Yellow }
+
+$existing = @{}
+foreach ($i in (Get-JiraIssues "project = $ProjectKey ORDER BY created ASC" @('summary'))) { $existing[($i.fields.summary).Trim()] = $true }
+Write-Host ("{0} existing issues in {1}." -f $existing.Count, $ProjectKey) -ForegroundColor Cyan
+
+function New-Issue {
+ param([hashtable] $Fields, [string[]] $Labels, [string] $Adf, [string] $LocalKey, [string] $Summary)
+ $f = @{} + $Fields; $f['description'] = '__ADF__'; $f['labels'] = '__LABELS__'
+ $json = (@{ fields = $f } | ConvertTo-Json -Depth 20).Replace('"__ADF__"', $Adf).Replace('"__LABELS__"', (ConvertTo-LabelsJson $Labels))
+ if ($WhatIf) { Write-Host (" [WhatIf] create {0} {1}" -f $LocalKey, $Summary) -ForegroundColor DarkGray; return "DRYRUN-$LocalKey" }
+ try {
+ $resp = Invoke-RestMethod -Method Post -Uri "$global:JiraSite/rest/api/3/issue" -Headers $global:JiraHeaders -ContentType 'application/json' -Body $json
+ Write-Host (" created {0} -> {1} {2}" -f $LocalKey, $resp.key, $Summary) -ForegroundColor Green
+ return $resp.key
+ } catch { Write-Host (" FAILED {0} {1}`n {2}" -f $LocalKey, $Summary, (Get-ErrorBody $_)) -ForegroundColor Red; $script:failed++; return $null }
+}
+
+$keyMap = @{}; $created = 0; $skipped = 0; $failed = 0
+foreach ($pass in @('Epic', 'child')) {
+ Write-Host ("`n== {0} ==" -f $(if ($pass -eq 'Epic') { 'Pass 1: Epics' } else { 'Pass 2: Children' })) -ForegroundColor Cyan
+ foreach ($r in $rows) {
+ $isEpic = $r.'Issue Type' -eq 'Epic'
+ if ($pass -eq 'Epic' -and -not $isEpic) { continue }
+ if ($pass -eq 'child' -and $isEpic) { continue }
+ $summary = ($r.Summary).Trim()
+ if ($existing.ContainsKey($summary)) { Write-Host (" skip (exists) {0}" -f $summary) -ForegroundColor DarkYellow; $skipped++; continue }
+ $p = Split-AcDescription $r.Description
+ $adf = Build-AdfDoc $p.Lead $p.Items
+ $labels = @($r.Labels -split '[,\s]+' | Where-Object { $_ })
+ $fields = @{ project = @{ key = $ProjectKey }; summary = $r.Summary; issuetype = @{ name = $r.'Issue Type' } }
+ if (-not $isEpic -and $r.Parent) {
+ $pk = $keyMap[$r.Parent]
+ if ($pk) { $fields.parent = @{ key = $pk } }
+ elseif (-not $WhatIf) { Write-Host (" (no mapped parent {0} for {1})" -f $r.Parent, $r.'Issue Key') -ForegroundColor Yellow }
+ }
+ $nk = New-Issue -Fields $fields -Labels $labels -Adf $adf -LocalKey $r.'Issue Key' -Summary $r.Summary
+ if ($nk) { $keyMap[$r.'Issue Key'] = $nk; $created++ }
+ }
+}
+
+Write-Host "`n== Summary ==" -ForegroundColor Cyan
+if ($WhatIf) { Write-Host "(dry run - nothing created)" -ForegroundColor DarkGray }
+Write-Host ("Created: {0} Skipped (exists): {1} Failed: {2}" -f $created, $skipped, $failed)
diff --git a/docs/planning/Jira/link-issues.ps1 b/docs/planning/Jira/link-issues.ps1
new file mode 100644
index 0000000..e2c6732
--- /dev/null
+++ b/docs/planning/Jira/link-issues.ps1
@@ -0,0 +1,92 @@
+<#
+ link-issues.ps1 - create Jira issue links from a CSV.
+
+ CSV columns: LinkType, From, To
+ - LinkType : Blocks | Relates
+ - Blocks : "From is blocked by To" (From depends on To; To must ship first).
+ - Relates : symmetric association (direction irrelevant).
+ - From / To: a real Jira key (e.g. WKP-90) OR an exact issue summary. Resolved against the
+ whole project, so you can mix keys and summaries.
+
+ Blocks direction self-calibrates (creates, reads back, flips if needed) so it is correct
+ regardless of how the instance maps inwardIssue/outwardIssue. Re-runnable: a correct link is
+ left alone, an inverted Blocks link is fixed, an existing Relates link is left alone. -WhatIf dry-runs.
+
+ Usage:
+ ./link-issues.ps1 -SiteUrl https://your.atlassian.net -Email you@x.com -EdgesCsv examples/links-backlog.csv -WhatIf
+#>
+param(
+ [Parameter(Mandatory)] [string] $SiteUrl,
+ [Parameter(Mandatory)] [string] $Email,
+ [Parameter(Mandatory)] [string] $EdgesCsv,
+ [string] $ProjectKey = 'WKP',
+ [string] $ApiToken,
+ [switch] $WhatIf
+)
+$ErrorActionPreference = 'Stop'
+. "$PSScriptRoot\JiraLib.ps1"
+
+if (-not (Test-Path $EdgesCsv)) { throw "Edges CSV not found: $EdgesCsv" }
+$edges = Import-Csv -LiteralPath $EdgesCsv
+Write-Host ("Loaded {0} edges from {1}" -f $edges.Count, $EdgesCsv) -ForegroundColor Cyan
+
+Connect-Jira -SiteUrl $SiteUrl -Email $Email -ApiToken $ApiToken | Out-Null
+
+$lt = Invoke-RestMethod -Method Get -Uri "$global:JiraSite/rest/api/3/issueLinkType" -Headers $global:JiraHeaders
+$blockLT = $lt.issueLinkTypes | Where-Object { $_.name -eq 'Blocks' } | Select-Object -First 1
+$relateLT = $lt.issueLinkTypes | Where-Object { $_.name -eq 'Relates' } | Select-Object -First 1
+
+$index = Get-JiraIndex (Get-JiraIssues "project = $ProjectKey ORDER BY created ASC" @('summary', 'issuelinks'))
+Write-Host ("Indexed {0} project issues." -f $index.ByKey.Count) -ForegroundColor Cyan
+if ($WhatIf) { Write-Host "`n*** DRY RUN (-WhatIf): no links created ***`n" -ForegroundColor Yellow }
+
+$created = 0; $fixed = 0; $skipped = 0; $failed = 0
+
+foreach ($e in $edges) {
+ $type = ($e.LinkType).Trim()
+ $from = Resolve-JiraIssue $index $e.From
+ $to = Resolve-JiraIssue $index $e.To
+ if (-not $from -or -not $to) {
+ Write-Host ("`n! could not resolve edge {0} '{1}' -> '{2}'" -f $type, $e.From, $e.To) -ForegroundColor Red; $failed++; continue
+ }
+
+ if ($type -eq 'Relates') {
+ Write-Host ("`n{0} relates to {1}" -f $from.key, $to.key) -ForegroundColor White
+ if (-not $relateLT) { Write-Host " ! no 'Relates' link type" -ForegroundColor Red; $failed++; continue }
+ if (Get-PartnerSide $from $to.key 'Relates') { Write-Host " exists - skip" -ForegroundColor DarkGray; $skipped++; continue }
+ if ($WhatIf) { Write-Host (" [WhatIf] link {0} relates to {1}" -f $from.key, $to.key) -ForegroundColor DarkGray; $created++; continue }
+ try { New-IssueLinkById 'Relates' $from.id $to.id; Write-Host (" created: {0} relates to {1}" -f $from.key, $to.key) -ForegroundColor Green; $created++ }
+ catch { Write-Host (" FAILED: {0}" -f (Get-ErrorBody $_)) -ForegroundColor Red; $failed++ }
+ continue
+ }
+
+ # Blocks: "From is blocked by To" -> from the dependent's view, the blocker is its INWARD issue.
+ $dep = $from; $blk = $to
+ Write-Host ("`n{0} is blocked by {1}" -f $dep.key, $blk.key) -ForegroundColor White
+ if (-not $blockLT) { Write-Host " ! no 'Blocks' link type" -ForegroundColor Red; $failed++; continue }
+ try {
+ $cur = Get-PartnerSide $dep $blk.key 'Blocks'
+ if ($cur -and $cur.Side -eq 'in') { Write-Host " correct link exists - skip" -ForegroundColor DarkGray; $skipped++; continue }
+ if ($WhatIf) {
+ if ($cur) { Write-Host " [WhatIf] inverted -> would fix" -ForegroundColor DarkGray; $fixed++ }
+ else { Write-Host (" [WhatIf] would create '{0} is blocked by {1}'" -f $dep.key, $blk.key) -ForegroundColor DarkGray; $created++ }
+ continue
+ }
+ if ($cur -and $cur.Side -eq 'out') { Write-Host " inverted - deleting" -ForegroundColor Yellow; Remove-IssueLink $cur.Id; $fixed++ }
+ # correct mapping: blocker = inwardIssue, dependent = outwardIssue
+ New-IssueLinkById 'Blocks' $blk.id $dep.id
+ $ps = Get-PartnerSide (Get-IssueLinksFresh $dep.key) $blk.key 'Blocks'
+ if ($ps -and $ps.Side -eq 'in') { Write-Host (" created: {0} {1} {2}" -f $dep.key, $blockLT.inward, $blk.key) -ForegroundColor Green; $created++ }
+ else {
+ if ($ps) { Remove-IssueLink $ps.Id }
+ New-IssueLinkById 'Blocks' $dep.id $blk.id
+ $ps2 = Get-PartnerSide (Get-IssueLinksFresh $dep.key) $blk.key 'Blocks'
+ if ($ps2 -and $ps2.Side -eq 'in') { Write-Host (" created (flipped): {0} {1} {2}" -f $dep.key, $blockLT.inward, $blk.key) -ForegroundColor Green; $created++ }
+ else { Write-Host " ! could not establish direction - check manually" -ForegroundColor Red; $failed++ }
+ }
+ } catch { Write-Host (" FAILED: {0}" -f (Get-ErrorBody $_)) -ForegroundColor Red; $failed++ }
+}
+
+Write-Host "`n== Summary ==" -ForegroundColor Cyan
+if ($WhatIf) { Write-Host "(dry run - nothing changed)" -ForegroundColor DarkGray }
+Write-Host ("Created: {0} Fixed (inverted): {1} Skipped: {2} Failed: {3}" -f $created, $fixed, $skipped, $failed)
diff --git a/docs/planning/Jira/update-issues.ps1 b/docs/planning/Jira/update-issues.ps1
new file mode 100644
index 0000000..437b49b
--- /dev/null
+++ b/docs/planning/Jira/update-issues.ps1
@@ -0,0 +1,91 @@
+<#
+ update-issues.ps1 - apply a batch of issue edits from a JSON change set.
+
+ JSON: an array of objects, each:
+ {
+ "key": "WKP-56" | "", (required - resolved by key or summary)
+ "newSummary": "...", (optional - retitle)
+ "note": "...", (optional - APPENDED to the description; idempotent: skipped
+ if the description already contains this exact text)
+ "transition": "Done", (optional - move to this status; skipped if already there)
+ "comment": "...", (optional - posted only when a transition is actually performed)
+ "duplicateOf": "WKP-83" (optional - add a Duplicate link to this issue)
+ }
+
+ Operates by numeric id (this account cannot GET issues by key). -WhatIf dry-runs.
+
+ Usage:
+ ./update-issues.ps1 -SiteUrl https://your.atlassian.net -Email you@x.com -ChangesJson examples/updates-audit-2026-06-29.json -WhatIf
+#>
+param(
+ [Parameter(Mandatory)] [string] $SiteUrl,
+ [Parameter(Mandatory)] [string] $Email,
+ [Parameter(Mandatory)] [string] $ChangesJson,
+ [string] $ProjectKey = 'WKP',
+ [string] $ApiToken,
+ [switch] $WhatIf
+)
+$ErrorActionPreference = 'Stop'
+. "$PSScriptRoot\JiraLib.ps1"
+
+if (-not (Test-Path $ChangesJson)) { throw "Changes JSON not found: $ChangesJson" }
+$changes = Get-Content -LiteralPath $ChangesJson -Raw -Encoding UTF8 | ConvertFrom-Json
+Write-Host ("Loaded {0} changes from {1}" -f @($changes).Count, $ChangesJson) -ForegroundColor Cyan
+
+Connect-Jira -SiteUrl $SiteUrl -Email $Email -ApiToken $ApiToken | Out-Null
+
+$index = Get-JiraIndex (Get-JiraIssues "project = $ProjectKey ORDER BY created ASC" @('summary', 'status', 'description', 'issuelinks'))
+Write-Host ("Indexed {0} project issues." -f $index.ByKey.Count) -ForegroundColor Cyan
+if ($WhatIf) { Write-Host "`n*** DRY RUN (-WhatIf): nothing sent ***`n" -ForegroundColor Yellow }
+
+function Update-IssueById { param([string] $Id, $Fields) Invoke-Jira PUT "/rest/api/3/issue/$Id" @{ fields = $Fields } | Out-Null }
+function Add-CommentById { param([string] $Id, [string] $Text) Invoke-Jira POST "/rest/api/3/issue/$Id/comment" (New-AdfComment $Text) | Out-Null }
+
+$retitled = 0; $noted = 0; $moved = 0; $linked = 0; $commented = 0; $skipped = 0; $failed = 0
+
+foreach ($c in $changes) {
+ $iss = Resolve-JiraIssue $index $c.key
+ if (-not $iss) { Write-Host ("`n! could not resolve '{0}'" -f $c.key) -ForegroundColor Red; $failed++; continue }
+ $id = $iss.id
+ Write-Host ("`n== {0} ({1}) ==" -f $c.key, $iss.key) -ForegroundColor Cyan
+ try {
+ # retitle
+ if ($c.newSummary -and $c.newSummary -ne ($iss.fields.summary).Trim()) {
+ if ($WhatIf) { Write-Host (" [WhatIf] retitle -> {0}" -f $c.newSummary) -ForegroundColor DarkGray }
+ else { Update-IssueById $id @{ summary = $c.newSummary }; Write-Host (" retitled -> {0}" -f $c.newSummary) -ForegroundColor Green }
+ $retitled++
+ }
+ # append note (idempotent: skip if the description already contains the note text)
+ if ($c.note) {
+ $descText = Get-AdfText $iss.fields.description
+ if ($descText -and $descText.Contains($c.note)) { Write-Host " note already present - skip" -ForegroundColor DarkGray; $skipped++ }
+ elseif ($WhatIf) { Write-Host (" [WhatIf] append note: " + $c.note.Substring(0, [Math]::Min(70, $c.note.Length)) + '...') -ForegroundColor DarkGray; $noted++ }
+ else { Update-IssueById $id @{ description = (Add-AdfNote $iss.fields.description $c.note) }; Write-Host " note appended" -ForegroundColor Green; $noted++ }
+ }
+ # transition (+ duplicate link + comment, only if not already in the target status)
+ if ($c.transition) {
+ if (($iss.fields.status.name) -eq $c.transition) { Write-Host (" already '{0}' - skip" -f $c.transition) -ForegroundColor DarkGray; $skipped++ }
+ else {
+ if ($c.duplicateOf) {
+ $tgt = Resolve-JiraIssue $index $c.duplicateOf
+ $has = $false
+ foreach ($l in @($iss.fields.issuelinks)) { if ($l.type.name -eq 'Duplicate' -and (($l.outwardIssue.key -eq ($tgt.key)) -or ($l.inwardIssue.key -eq ($tgt.key)))) { $has = $true } }
+ if (-not $tgt) { Write-Host (" ! duplicateOf '{0}' unresolved" -f $c.duplicateOf) -ForegroundColor Yellow }
+ elseif ($has) { Write-Host " duplicate link exists - skip" -ForegroundColor DarkGray }
+ elseif ($WhatIf) { Write-Host (" [WhatIf] link {0} duplicates {1}" -f $iss.key, $tgt.key) -ForegroundColor DarkGray; $linked++ }
+ else { Invoke-Jira POST '/rest/api/3/issueLink' @{ type = @{ name = 'Duplicate' }; inwardIssue = @{ id = $tgt.id }; outwardIssue = @{ id = $id } } | Out-Null; Write-Host (" linked: {0} duplicates {1}" -f $iss.key, $tgt.key) -ForegroundColor Green; $linked++ }
+ }
+ if ($c.comment) {
+ if ($WhatIf) { Write-Host " [WhatIf] add comment" -ForegroundColor DarkGray } else { Add-CommentById $id $c.comment; Write-Host " comment added" -ForegroundColor Green }
+ $commented++
+ }
+ if ($WhatIf) { Write-Host (" [WhatIf] transition -> {0}" -f $c.transition) -ForegroundColor DarkGray; $moved++ }
+ else { if (Move-IssueById $id $c.transition) { Write-Host (" transitioned -> {0}" -f $c.transition) -ForegroundColor Green; $moved++ } else { $failed++ } }
+ }
+ }
+ } catch { Write-Host (" FAILED: {0}" -f (Get-ErrorBody $_)) -ForegroundColor Red; $failed++ }
+}
+
+Write-Host "`n== Summary ==" -ForegroundColor Cyan
+if ($WhatIf) { Write-Host "(dry run - nothing changed)" -ForegroundColor DarkGray }
+Write-Host ("Retitled: {0} Notes: {1} Transitioned: {2} Linked: {3} Comments: {4} Skipped: {5} Failed: {6}" -f $retitled, $noted, $moved, $linked, $commented, $skipped, $failed)
diff --git a/lang/en/auth.php b/lang/en/auth.php
new file mode 100644
index 0000000..6598e2c
--- /dev/null
+++ b/lang/en/auth.php
@@ -0,0 +1,20 @@
+ 'These credentials do not match our records.',
+ 'password' => 'The provided password is incorrect.',
+ 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+
+];
diff --git a/lang/en/emails.php b/lang/en/emails.php
new file mode 100644
index 0000000..808e298
--- /dev/null
+++ b/lang/en/emails.php
@@ -0,0 +1,59 @@
+ [
+ 'fallback' => "If the button doesn't work, copy and paste this link into your browser:",
+ 'security_footer' => 'This is an automated security notification from WordKeep.',
+ ],
+
+ 'verify' => [
+ 'subject' => 'Verify Your WordKeep Email',
+ 'title' => 'Welcome, :name!',
+ 'intro' => 'Thanks for signing up for WordKeep. To complete your registration and activate your account, please verify your email address by clicking the button below.',
+ 'button' => 'Verify Email Address',
+ 'expiry' => "This link will expire in :days days. If you don't verify your email within this time, your account will be automatically deleted.",
+ 'ignore' => "If you didn't create an account with WordKeep, you can safely ignore this email.",
+ ],
+
+ 'reset' => [
+ 'subject' => 'Reset Your WordKeep Password',
+ 'title' => 'Reset Your Password',
+ 'intro' => 'Hi :name, we received a request to reset your WordKeep password. Click the button below to choose a new password.',
+ 'button' => 'Reset Password',
+ 'expiry' => 'This link will expire in :minutes minutes.',
+ 'ignore' => "If you didn't request a password reset, you can safely ignore this email.",
+ ],
+
+ 'changed' => [
+ 'subject' => 'Your WordKeep Password Was Changed',
+ 'title' => 'Password changed, :name',
+ 'intro' => 'Your WordKeep password was just changed. If you made this change, no further action is needed.',
+ 'warning' => 'If you did not change your password, please reset it immediately using the "Forgot password?" link on the login page.',
+ ],
+
+ 'two_factor_enabled' => [
+ 'subject' => 'Two-Factor Authentication Enabled',
+ 'title' => 'Two-factor authentication enabled, :name',
+ 'intro' => 'Two-factor authentication has been enabled on your WordKeep account. You will now be asked for a 6-digit code from your authenticator app each time you sign in.',
+ 'recovery' => 'Make sure to keep your recovery codes in a safe place — they are the only way to access your account if you lose your authenticator device.',
+ 'warning' => 'If you did not make this change, please contact us immediately.',
+ ],
+
+ 'two_factor_disabled' => [
+ 'subject' => 'Two-Factor Authentication Disabled',
+ 'title' => 'Two-factor authentication disabled, :name',
+ 'intro' => 'Two-factor authentication has been disabled on your WordKeep account. You will no longer be asked for a code when signing in.',
+ 'warning' => 'If you did not make this change, your account may be compromised. Please change your password immediately and re-enable two-factor authentication.',
+ ],
+
+ 'verify_change' => [
+ 'subject' => 'Verify Your New WordKeep Email',
+ 'title' => 'Confirm your new email, :name',
+ 'intro' => 'You requested to change your WordKeep email address to this one. Click the button below to confirm the change.',
+ 'button' => 'Confirm New Email',
+ 'expiry' => 'This link will expire in :days days. If not confirmed, your email address will remain unchanged.',
+ 'ignore' => "If you didn't request this email change, you can safely ignore this email. Your current email address remains active.",
+ ],
+
+];
diff --git a/lang/en/messages.php b/lang/en/messages.php
new file mode 100644
index 0000000..783be87
--- /dev/null
+++ b/lang/en/messages.php
@@ -0,0 +1,89 @@
+ [
+ 'registered' => 'Registration successful. Please check your email to verify your account.',
+ 'email_not_verified' => 'Please verify your email before logging in. Check your inbox for the verification link.',
+ 'challenge_invalid' => 'Invalid or expired challenge.',
+ 'challenge_expired' => 'Challenge expired. Please log in again.',
+ 'code_invalid' => 'Invalid code.',
+ 'verification_token_invalid' => 'Invalid or expired verification token.',
+ 'email_verified' => 'Email verified successfully.',
+ 'verification_resent' => 'If an unverified account exists, a verification email has been sent.',
+ 'reset_link_sent' => 'If an account exists, a password reset link has been sent.',
+ 'reset_token_invalid' => 'Invalid or expired reset token.',
+ 'reset_link_expired' => 'Reset link has expired. Please request a new one.',
+ 'password_reset' => 'Password reset successful.',
+ 'logged_out' => 'Successfully logged out',
+ ],
+
+ 'profile' => [
+ 'password_verified' => 'Password verified.',
+ 'email_change_sent' => 'Verification email sent to :email.',
+ 'no_pending_email_change' => 'No pending email change.',
+ 'verification_resent' => 'Verification email resent.',
+ 'email_change_token_invalid' => 'Invalid or expired token.',
+ 'email_change_expired' => 'This link has expired. Please request a new email change.',
+ 'email_updated' => 'Email updated successfully.',
+ 'password_updated' => 'Password updated.',
+ 'two_factor_not_initiated' => 'Two-factor setup not initiated.',
+ 'two_factor_code_invalid' => 'Invalid verification code.',
+ ],
+
+ 'documents' => [
+ 'retry_only_failed' => 'Only failed documents can be retried',
+ ],
+
+ 'pages' => [
+ 'image_unavailable' => 'Page image could not be generated.',
+ 'not_found' => 'Page :page not found.',
+ 'not_found_plain' => 'Page not found',
+ ],
+
+ 'ocr' => [
+ 'footnote_use_insert' => 'Use the insert button to add footnote references.',
+ 'footnote_use_unlink' => 'Use the unlink button to remove footnote references.',
+ ],
+
+ 'translation' => [
+ 'page_no_ocr' => 'Page has no OCR text to translate.',
+ 'not_found_for_page' => 'No translation found for this page and language.',
+ 'not_found_plain' => 'Translation not found',
+ 'document_not_ready' => 'Document is not ready.',
+ 'job_already_running' => 'A translation job for this language is already running.',
+ 'no_running_job' => 'No running translation job to cancel.',
+ 'deleted' => 'Translation deleted.',
+ 'footnote_failed' => 'Page translated, but footnote [^:number] failed: :error',
+ 'footnote_failed_short' => 'Footnote [^:number] failed to translate: :error',
+ ],
+
+ 'page_linking' => [
+ 'invalid_page' => 'Invalid page',
+ 'ignored_page' => 'Cannot set link for ignored page',
+ ],
+
+ 'footnotes' => [
+ 'not_linked' => 'Footnote is not linked to a page',
+ ],
+
+ 'bulk_ocr' => [
+ 'not_ready' => 'Document is not ready for OCR processing.',
+ 'already_running' => 'Bulk OCR is already running for this document.',
+ 'queued' => 'Bulk OCR job queued successfully.',
+ 'no_running_job' => 'No bulk OCR job is running for this document.',
+ 'cancelled' => 'Bulk OCR job cancelled.',
+ ],
+
+];
diff --git a/lang/en/pagination.php b/lang/en/pagination.php
new file mode 100644
index 0000000..d481411
--- /dev/null
+++ b/lang/en/pagination.php
@@ -0,0 +1,19 @@
+ '« Previous',
+ 'next' => 'Next »',
+
+];
diff --git a/lang/en/passwords.php b/lang/en/passwords.php
new file mode 100644
index 0000000..fad3a7d
--- /dev/null
+++ b/lang/en/passwords.php
@@ -0,0 +1,22 @@
+ 'Your password has been reset.',
+ 'sent' => 'We have emailed your password reset link.',
+ 'throttled' => 'Please wait before retrying.',
+ 'token' => 'This password reset token is invalid.',
+ 'user' => "We can't find a user with that email address.",
+
+];
diff --git a/lang/en/validation.php b/lang/en/validation.php
new file mode 100644
index 0000000..244a33f
--- /dev/null
+++ b/lang/en/validation.php
@@ -0,0 +1,210 @@
+ 'The :attribute field must be accepted.',
+ 'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
+ 'active_url' => 'The :attribute field must be a valid URL.',
+ 'after' => 'The :attribute field must be a date after :date.',
+ 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
+ 'alpha' => 'The :attribute field must only contain letters.',
+ 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
+ 'alpha_num' => 'The :attribute field must only contain letters and numbers.',
+ 'any_of' => 'The :attribute field is invalid.',
+ 'array' => 'The :attribute field must be an array.',
+ 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
+ 'before' => 'The :attribute field must be a date before :date.',
+ 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
+ 'between' => [
+ 'array' => 'The :attribute field must have between :min and :max items.',
+ 'file' => 'The :attribute field must be between :min and :max kilobytes.',
+ 'numeric' => 'The :attribute field must be between :min and :max.',
+ 'string' => 'The :attribute field must be between :min and :max characters.',
+ ],
+ 'boolean' => 'The :attribute field must be true or false.',
+ 'can' => 'The :attribute field contains an unauthorized value.',
+ 'confirmed' => 'The :attribute field confirmation does not match.',
+ 'contains' => 'The :attribute field is missing a required value.',
+ 'current_password' => 'The password is incorrect.',
+ 'date' => 'The :attribute field must be a valid date.',
+ 'date_equals' => 'The :attribute field must be a date equal to :date.',
+ 'date_format' => 'The :attribute field must match the format :format.',
+ 'decimal' => 'The :attribute field must have :decimal decimal places.',
+ 'declined' => 'The :attribute field must be declined.',
+ 'declined_if' => 'The :attribute field must be declined when :other is :value.',
+ 'different' => 'The :attribute field and :other must be different.',
+ 'digits' => 'The :attribute field must be :digits digits.',
+ 'digits_between' => 'The :attribute field must be between :min and :max digits.',
+ 'dimensions' => 'The :attribute field has invalid image dimensions.',
+ 'distinct' => 'The :attribute field has a duplicate value.',
+ 'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
+ 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
+ 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
+ 'email' => 'The :attribute field must be a valid email address.',
+ 'encoding' => 'The :attribute field must be encoded in :encoding.',
+ 'ends_with' => 'The :attribute field must end with one of the following: :values.',
+ 'enum' => 'The selected :attribute is invalid.',
+ 'exists' => 'The selected :attribute is invalid.',
+ 'extensions' => 'The :attribute field must have one of the following extensions: :values.',
+ 'file' => 'The :attribute field must be a file.',
+ 'filled' => 'The :attribute field must have a value.',
+ 'gt' => [
+ 'array' => 'The :attribute field must have more than :value items.',
+ 'file' => 'The :attribute field must be greater than :value kilobytes.',
+ 'numeric' => 'The :attribute field must be greater than :value.',
+ 'string' => 'The :attribute field must be greater than :value characters.',
+ ],
+ 'gte' => [
+ 'array' => 'The :attribute field must have :value items or more.',
+ 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
+ 'numeric' => 'The :attribute field must be greater than or equal to :value.',
+ 'string' => 'The :attribute field must be greater than or equal to :value characters.',
+ ],
+ 'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
+ 'image' => 'The :attribute field must be an image.',
+ 'in' => 'The selected :attribute is invalid.',
+ 'in_array' => 'The :attribute field must exist in :other.',
+ 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
+ 'integer' => 'The :attribute field must be an integer.',
+ 'ip' => 'The :attribute field must be a valid IP address.',
+ 'ipv4' => 'The :attribute field must be a valid IPv4 address.',
+ 'ipv6' => 'The :attribute field must be a valid IPv6 address.',
+ 'json' => 'The :attribute field must be a valid JSON string.',
+ 'list' => 'The :attribute field must be a list.',
+ 'lowercase' => 'The :attribute field must be lowercase.',
+ 'lt' => [
+ 'array' => 'The :attribute field must have less than :value items.',
+ 'file' => 'The :attribute field must be less than :value kilobytes.',
+ 'numeric' => 'The :attribute field must be less than :value.',
+ 'string' => 'The :attribute field must be less than :value characters.',
+ ],
+ 'lte' => [
+ 'array' => 'The :attribute field must not have more than :value items.',
+ 'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
+ 'numeric' => 'The :attribute field must be less than or equal to :value.',
+ 'string' => 'The :attribute field must be less than or equal to :value characters.',
+ ],
+ 'mac_address' => 'The :attribute field must be a valid MAC address.',
+ 'max' => [
+ 'array' => 'The :attribute field must not have more than :max items.',
+ 'file' => 'The :attribute field must not be greater than :max kilobytes.',
+ 'numeric' => 'The :attribute field must not be greater than :max.',
+ 'string' => 'The :attribute field must not be greater than :max characters.',
+ ],
+ 'max_digits' => 'The :attribute field must not have more than :max digits.',
+ 'mimes' => 'The :attribute field must be a file of type: :values.',
+ 'mimetypes' => 'The :attribute field must be a file of type: :values.',
+ 'min' => [
+ 'array' => 'The :attribute field must have at least :min items.',
+ 'file' => 'The :attribute field must be at least :min kilobytes.',
+ 'numeric' => 'The :attribute field must be at least :min.',
+ 'string' => 'The :attribute field must be at least :min characters.',
+ ],
+ 'min_digits' => 'The :attribute field must have at least :min digits.',
+ 'missing' => 'The :attribute field must be missing.',
+ 'missing_if' => 'The :attribute field must be missing when :other is :value.',
+ 'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
+ 'missing_with' => 'The :attribute field must be missing when :values is present.',
+ 'missing_with_all' => 'The :attribute field must be missing when :values are present.',
+ 'multiple_of' => 'The :attribute field must be a multiple of :value.',
+ 'not_in' => 'The selected :attribute is invalid.',
+ 'not_regex' => 'The :attribute field format is invalid.',
+ 'numeric' => 'The :attribute field must be a number.',
+ 'password' => [
+ 'letters' => 'The :attribute field must contain at least one letter.',
+ 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
+ 'numbers' => 'The :attribute field must contain at least one number.',
+ 'symbols' => 'The :attribute field must contain at least one symbol.',
+ 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
+ ],
+ 'present' => 'The :attribute field must be present.',
+ 'present_if' => 'The :attribute field must be present when :other is :value.',
+ 'present_unless' => 'The :attribute field must be present unless :other is :value.',
+ 'present_with' => 'The :attribute field must be present when :values is present.',
+ 'present_with_all' => 'The :attribute field must be present when :values are present.',
+ 'prohibited' => 'The :attribute field is prohibited.',
+ 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
+ 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
+ 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
+ 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
+ 'prohibits' => 'The :attribute field prohibits :other from being present.',
+ 'regex' => 'The :attribute field format is invalid.',
+ 'required' => 'The :attribute field is required.',
+ 'required_array_keys' => 'The :attribute field must contain entries for: :values.',
+ 'required_if' => 'The :attribute field is required when :other is :value.',
+ 'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
+ 'required_if_declined' => 'The :attribute field is required when :other is declined.',
+ 'required_unless' => 'The :attribute field is required unless :other is in :values.',
+ 'required_with' => 'The :attribute field is required when :values is present.',
+ 'required_with_all' => 'The :attribute field is required when :values are present.',
+ 'required_without' => 'The :attribute field is required when :values is not present.',
+ 'required_without_all' => 'The :attribute field is required when none of :values are present.',
+ 'same' => 'The :attribute field must match :other.',
+ 'size' => [
+ 'array' => 'The :attribute field must contain :size items.',
+ 'file' => 'The :attribute field must be :size kilobytes.',
+ 'numeric' => 'The :attribute field must be :size.',
+ 'string' => 'The :attribute field must be :size characters.',
+ ],
+ 'starts_with' => 'The :attribute field must start with one of the following: :values.',
+ 'string' => 'The :attribute field must be a string.',
+ 'timezone' => 'The :attribute field must be a valid timezone.',
+ 'unique' => 'The :attribute has already been taken.',
+ 'uploaded' => 'The :attribute failed to upload.',
+ 'uppercase' => 'The :attribute field must be uppercase.',
+ 'url' => 'The :attribute field must be a valid URL.',
+ 'ulid' => 'The :attribute field must be a valid ULID.',
+ 'uuid' => 'The :attribute field must be a valid UUID.',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Validation Language Lines
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify custom validation messages for attributes using the
+ | convention "attribute.rule" to name the lines. This makes it quick to
+ | specify a specific custom language line for a given attribute rule.
+ |
+ */
+
+ 'custom' => [
+ 'file' => [
+ 'required' => 'A PDF file is required.',
+ 'mimes' => 'The file must be a PDF document.',
+ 'max' => 'The PDF file must not exceed 100MB.',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Validation Attributes
+ |--------------------------------------------------------------------------
+ |
+ | The following language lines are used to swap our attribute placeholder
+ | with something more reader friendly such as "E-Mail Address" instead
+ | of "email". This simply helps us make our message more expressive.
+ |
+ */
+
+ 'attributes' => [
+ 'name' => 'name',
+ 'email' => 'email address',
+ 'new_email' => 'new email address',
+ 'password' => 'password',
+ 'current_password' => 'current password',
+ 'new_password' => 'new password',
+ 'file' => 'file',
+ ],
+
+];
diff --git a/lang/ro/auth.php b/lang/ro/auth.php
new file mode 100644
index 0000000..bbf1115
--- /dev/null
+++ b/lang/ro/auth.php
@@ -0,0 +1,15 @@
+ 'Aceste date de autentificare nu corespund înregistrărilor noastre.',
+ 'password' => 'Parola furnizată este incorectă.',
+ 'throttle' => 'Prea multe încercări de autentificare. Te rugăm să încerci din nou peste :seconds secunde.',
+
+];
diff --git a/lang/ro/emails.php b/lang/ro/emails.php
new file mode 100644
index 0000000..9045268
--- /dev/null
+++ b/lang/ro/emails.php
@@ -0,0 +1,59 @@
+ [
+ 'fallback' => 'Dacă butonul nu funcționează, copiază și lipește acest link în browser:',
+ 'security_footer' => 'Aceasta este o notificare de securitate automată de la WordKeep.',
+ ],
+
+ 'verify' => [
+ 'subject' => 'Confirmă-ți adresa de e-mail WordKeep',
+ 'title' => 'Bun venit, :name!',
+ 'intro' => 'Îți mulțumim că te-ai înscris pe WordKeep. Pentru a finaliza înregistrarea și a-ți activa contul, te rugăm să-ți confirmi adresa de e-mail apăsând butonul de mai jos.',
+ 'button' => 'Confirmă adresa de e-mail',
+ 'expiry' => 'Acest link va expira în :days zile. Dacă nu îți confirmi e-mailul în acest interval, contul tău va fi șters automat.',
+ 'ignore' => 'Dacă nu ți-ai creat un cont pe WordKeep, poți ignora în siguranță acest e-mail.',
+ ],
+
+ 'reset' => [
+ 'subject' => 'Resetează-ți parola WordKeep',
+ 'title' => 'Resetează-ți parola',
+ 'intro' => 'Salut :name, am primit o solicitare de resetare a parolei tale WordKeep. Apasă butonul de mai jos pentru a alege o parolă nouă.',
+ 'button' => 'Resetează parola',
+ 'expiry' => 'Acest link va expira în :minutes minute.',
+ 'ignore' => 'Dacă nu ai cerut o resetare a parolei, poți ignora în siguranță acest e-mail.',
+ ],
+
+ 'changed' => [
+ 'subject' => 'Parola ta WordKeep a fost schimbată',
+ 'title' => 'Parolă schimbată, :name',
+ 'intro' => 'Parola ta WordKeep tocmai a fost schimbată. Dacă tu ai făcut această modificare, nu este nevoie de nicio altă acțiune.',
+ 'warning' => 'Dacă nu ți-ai schimbat parola, te rugăm să o resetezi imediat folosind linkul „Ai uitat parola?” de pe pagina de autentificare.',
+ ],
+
+ 'two_factor_enabled' => [
+ 'subject' => 'Autentificarea în doi pași a fost activată',
+ 'title' => 'Autentificare în doi pași activată, :name',
+ 'intro' => 'Autentificarea în doi pași a fost activată pe contul tău WordKeep. De acum, ți se va cere un cod din 6 cifre din aplicația ta de autentificare la fiecare conectare.',
+ 'recovery' => 'Asigură-te că păstrezi codurile de recuperare într-un loc sigur — sunt singura cale de a-ți accesa contul dacă îți pierzi dispozitivul de autentificare.',
+ 'warning' => 'Dacă nu tu ai făcut această modificare, te rugăm să ne contactezi imediat.',
+ ],
+
+ 'two_factor_disabled' => [
+ 'subject' => 'Autentificarea în doi pași a fost dezactivată',
+ 'title' => 'Autentificare în doi pași dezactivată, :name',
+ 'intro' => 'Autentificarea în doi pași a fost dezactivată pe contul tău WordKeep. Nu ți se va mai cere un cod la conectare.',
+ 'warning' => 'Dacă nu tu ai făcut această modificare, contul tău ar putea fi compromis. Te rugăm să îți schimbi parola imediat și să reactivezi autentificarea în doi pași.',
+ ],
+
+ 'verify_change' => [
+ 'subject' => 'Confirmă noua adresă de e-mail WordKeep',
+ 'title' => 'Confirmă noua ta adresă de e-mail, :name',
+ 'intro' => 'Ai solicitat schimbarea adresei tale de e-mail WordKeep cu aceasta. Apasă butonul de mai jos pentru a confirma modificarea.',
+ 'button' => 'Confirmă noul e-mail',
+ 'expiry' => 'Acest link va expira în :days zile. Dacă nu este confirmat, adresa ta de e-mail va rămâne neschimbată.',
+ 'ignore' => 'Dacă nu ai cerut această schimbare de e-mail, poți ignora în siguranță acest e-mail. Adresa ta de e-mail actuală rămâne activă.',
+ ],
+
+];
diff --git a/lang/ro/messages.php b/lang/ro/messages.php
new file mode 100644
index 0000000..f23ee94
--- /dev/null
+++ b/lang/ro/messages.php
@@ -0,0 +1,89 @@
+ [
+ 'registered' => 'Înregistrare reușită. Verifică-ți e-mailul pentru a-ți confirma contul.',
+ 'email_not_verified' => 'Confirmă-ți adresa de e-mail înainte de autentificare. Verifică-ți inboxul pentru linkul de confirmare.',
+ 'challenge_invalid' => 'Verificare invalidă sau expirată.',
+ 'challenge_expired' => 'Verificarea a expirat. Te rugăm să te autentifici din nou.',
+ 'code_invalid' => 'Cod invalid.',
+ 'verification_token_invalid' => 'Token de confirmare invalid sau expirat.',
+ 'email_verified' => 'Adresa de e-mail a fost confirmată cu succes.',
+ 'verification_resent' => 'Dacă există un cont neconfirmat, a fost trimis un e-mail de confirmare.',
+ 'reset_link_sent' => 'Dacă există un cont, a fost trimis un link de resetare a parolei.',
+ 'reset_token_invalid' => 'Token de resetare invalid sau expirat.',
+ 'reset_link_expired' => 'Linkul de resetare a expirat. Te rugăm să soliciți unul nou.',
+ 'password_reset' => 'Parolă resetată cu succes.',
+ 'logged_out' => 'Deconectare reușită',
+ ],
+
+ 'profile' => [
+ 'password_verified' => 'Parolă verificată.',
+ 'email_change_sent' => 'E-mail de confirmare trimis către :email.',
+ 'no_pending_email_change' => 'Nicio schimbare de e-mail în așteptare.',
+ 'verification_resent' => 'E-mail de confirmare retrimis.',
+ 'email_change_token_invalid' => 'Token invalid sau expirat.',
+ 'email_change_expired' => 'Acest link a expirat. Te rugăm să soliciți o nouă schimbare de e-mail.',
+ 'email_updated' => 'Adresa de e-mail a fost actualizată cu succes.',
+ 'password_updated' => 'Parolă actualizată.',
+ 'two_factor_not_initiated' => 'Configurarea autentificării în doi pași nu a fost inițiată.',
+ 'two_factor_code_invalid' => 'Cod de verificare invalid.',
+ ],
+
+ 'documents' => [
+ 'retry_only_failed' => 'Doar documentele eșuate pot fi reîncercate',
+ ],
+
+ 'pages' => [
+ 'image_unavailable' => 'Imaginea paginii nu a putut fi generată.',
+ 'not_found' => 'Pagina :page nu a fost găsită.',
+ 'not_found_plain' => 'Pagina nu a fost găsită',
+ ],
+
+ 'ocr' => [
+ 'footnote_use_insert' => 'Folosește butonul de inserare pentru a adăuga referințe la note de subsol.',
+ 'footnote_use_unlink' => 'Folosește butonul de dezlegare pentru a elimina referințe la note de subsol.',
+ ],
+
+ 'translation' => [
+ 'page_no_ocr' => 'Pagina nu are text OCR de tradus.',
+ 'not_found_for_page' => 'Nu s-a găsit nicio traducere pentru această pagină și limbă.',
+ 'not_found_plain' => 'Traducere negăsită',
+ 'document_not_ready' => 'Documentul nu este pregătit.',
+ 'job_already_running' => 'O sarcină de traducere pentru această limbă este deja în curs.',
+ 'no_running_job' => 'Nicio sarcină de traducere în curs de anulat.',
+ 'deleted' => 'Traducere ștearsă.',
+ 'footnote_failed' => 'Pagina a fost tradusă, dar nota de subsol [^:number] a eșuat: :error',
+ 'footnote_failed_short' => 'Nota de subsol [^:number] nu a putut fi tradusă: :error',
+ ],
+
+ 'page_linking' => [
+ 'invalid_page' => 'Pagină invalidă',
+ 'ignored_page' => 'Nu se poate seta legătura pentru o pagină ignorată',
+ ],
+
+ 'footnotes' => [
+ 'not_linked' => 'Nota de subsol nu este legată de o pagină',
+ ],
+
+ 'bulk_ocr' => [
+ 'not_ready' => 'Documentul nu este pregătit pentru procesare OCR.',
+ 'already_running' => 'OCR în masă rulează deja pentru acest document.',
+ 'queued' => 'Sarcina OCR în masă a fost pusă în coadă cu succes.',
+ 'no_running_job' => 'Nicio sarcină OCR în masă nu rulează pentru acest document.',
+ 'cancelled' => 'Sarcina OCR în masă a fost anulată.',
+ ],
+
+];
diff --git a/lang/ro/pagination.php b/lang/ro/pagination.php
new file mode 100644
index 0000000..ecc091a
--- /dev/null
+++ b/lang/ro/pagination.php
@@ -0,0 +1,14 @@
+ '« Anterior',
+ 'next' => 'Următor »',
+
+];
diff --git a/lang/ro/passwords.php b/lang/ro/passwords.php
new file mode 100644
index 0000000..3a901a7
--- /dev/null
+++ b/lang/ro/passwords.php
@@ -0,0 +1,17 @@
+ 'Parola ta a fost resetată.',
+ 'sent' => 'Ți-am trimis prin e-mail linkul de resetare a parolei.',
+ 'throttled' => 'Te rugăm să aștepți înainte de a încerca din nou.',
+ 'token' => 'Acest token de resetare a parolei este invalid.',
+ 'user' => 'Nu găsim niciun utilizator cu această adresă de e-mail.',
+
+];
diff --git a/lang/ro/validation.php b/lang/ro/validation.php
new file mode 100644
index 0000000..22e8db6
--- /dev/null
+++ b/lang/ro/validation.php
@@ -0,0 +1,183 @@
+ 'Câmpul :attribute trebuie acceptat.',
+ 'accepted_if' => 'Câmpul :attribute trebuie acceptat când :other este :value.',
+ 'active_url' => 'Câmpul :attribute trebuie să fie o adresă URL validă.',
+ 'after' => 'Câmpul :attribute trebuie să fie o dată după :date.',
+ 'after_or_equal' => 'Câmpul :attribute trebuie să fie o dată după sau egală cu :date.',
+ 'alpha' => 'Câmpul :attribute trebuie să conțină doar litere.',
+ 'alpha_dash' => 'Câmpul :attribute trebuie să conțină doar litere, cifre, liniuțe și liniuțe de subliniere.',
+ 'alpha_num' => 'Câmpul :attribute trebuie să conțină doar litere și cifre.',
+ 'any_of' => 'Câmpul :attribute este invalid.',
+ 'array' => 'Câmpul :attribute trebuie să fie o listă.',
+ 'ascii' => 'Câmpul :attribute trebuie să conțină doar caractere alfanumerice și simboluri pe un singur octet.',
+ 'before' => 'Câmpul :attribute trebuie să fie o dată înainte de :date.',
+ 'before_or_equal' => 'Câmpul :attribute trebuie să fie o dată înainte sau egală cu :date.',
+ 'between' => [
+ 'array' => 'Câmpul :attribute trebuie să aibă între :min și :max elemente.',
+ 'file' => 'Câmpul :attribute trebuie să aibă între :min și :max kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie între :min și :max.',
+ 'string' => 'Câmpul :attribute trebuie să aibă între :min și :max caractere.',
+ ],
+ 'boolean' => 'Câmpul :attribute trebuie să fie adevărat sau fals.',
+ 'can' => 'Câmpul :attribute conține o valoare neautorizată.',
+ 'confirmed' => 'Confirmarea câmpului :attribute nu se potrivește.',
+ 'contains' => 'Câmpului :attribute îi lipsește o valoare obligatorie.',
+ 'current_password' => 'Parola este incorectă.',
+ 'date' => 'Câmpul :attribute trebuie să fie o dată validă.',
+ 'date_equals' => 'Câmpul :attribute trebuie să fie o dată egală cu :date.',
+ 'date_format' => 'Câmpul :attribute trebuie să corespundă formatului :format.',
+ 'decimal' => 'Câmpul :attribute trebuie să aibă :decimal zecimale.',
+ 'declined' => 'Câmpul :attribute trebuie refuzat.',
+ 'declined_if' => 'Câmpul :attribute trebuie refuzat când :other este :value.',
+ 'different' => 'Câmpurile :attribute și :other trebuie să fie diferite.',
+ 'digits' => 'Câmpul :attribute trebuie să aibă :digits cifre.',
+ 'digits_between' => 'Câmpul :attribute trebuie să aibă între :min și :max cifre.',
+ 'dimensions' => 'Câmpul :attribute are dimensiuni de imagine invalide.',
+ 'distinct' => 'Câmpul :attribute are o valoare duplicată.',
+ 'doesnt_contain' => 'Câmpul :attribute nu trebuie să conțină niciuna dintre următoarele: :values.',
+ 'doesnt_end_with' => 'Câmpul :attribute nu trebuie să se termine cu una dintre următoarele: :values.',
+ 'doesnt_start_with' => 'Câmpul :attribute nu trebuie să înceapă cu una dintre următoarele: :values.',
+ 'email' => 'Câmpul :attribute trebuie să fie o adresă de e-mail validă.',
+ 'encoding' => 'Câmpul :attribute trebuie să fie codificat în :encoding.',
+ 'ends_with' => 'Câmpul :attribute trebuie să se termine cu una dintre următoarele: :values.',
+ 'enum' => 'Valoarea selectată pentru :attribute este invalidă.',
+ 'exists' => 'Valoarea selectată pentru :attribute este invalidă.',
+ 'extensions' => 'Câmpul :attribute trebuie să aibă una dintre extensiile: :values.',
+ 'file' => 'Câmpul :attribute trebuie să fie un fișier.',
+ 'filled' => 'Câmpul :attribute trebuie să aibă o valoare.',
+ 'gt' => [
+ 'array' => 'Câmpul :attribute trebuie să aibă mai mult de :value elemente.',
+ 'file' => 'Câmpul :attribute trebuie să fie mai mare de :value kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie mai mare de :value.',
+ 'string' => 'Câmpul :attribute trebuie să aibă mai mult de :value caractere.',
+ ],
+ 'gte' => [
+ 'array' => 'Câmpul :attribute trebuie să aibă :value elemente sau mai multe.',
+ 'file' => 'Câmpul :attribute trebuie să fie mai mare sau egal cu :value kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie mai mare sau egal cu :value.',
+ 'string' => 'Câmpul :attribute trebuie să aibă cel puțin :value caractere.',
+ ],
+ 'hex_color' => 'Câmpul :attribute trebuie să fie o culoare hexazecimală validă.',
+ 'image' => 'Câmpul :attribute trebuie să fie o imagine.',
+ 'in' => 'Valoarea selectată pentru :attribute este invalidă.',
+ 'in_array' => 'Câmpul :attribute trebuie să existe în :other.',
+ 'in_array_keys' => 'Câmpul :attribute trebuie să conțină cel puțin una dintre cheile: :values.',
+ 'integer' => 'Câmpul :attribute trebuie să fie un număr întreg.',
+ 'ip' => 'Câmpul :attribute trebuie să fie o adresă IP validă.',
+ 'ipv4' => 'Câmpul :attribute trebuie să fie o adresă IPv4 validă.',
+ 'ipv6' => 'Câmpul :attribute trebuie să fie o adresă IPv6 validă.',
+ 'json' => 'Câmpul :attribute trebuie să fie un șir JSON valid.',
+ 'list' => 'Câmpul :attribute trebuie să fie o listă.',
+ 'lowercase' => 'Câmpul :attribute trebuie să fie cu litere mici.',
+ 'lt' => [
+ 'array' => 'Câmpul :attribute trebuie să aibă mai puțin de :value elemente.',
+ 'file' => 'Câmpul :attribute trebuie să fie mai mic de :value kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie mai mic de :value.',
+ 'string' => 'Câmpul :attribute trebuie să aibă mai puțin de :value caractere.',
+ ],
+ 'lte' => [
+ 'array' => 'Câmpul :attribute nu trebuie să aibă mai mult de :value elemente.',
+ 'file' => 'Câmpul :attribute trebuie să fie mai mic sau egal cu :value kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie mai mic sau egal cu :value.',
+ 'string' => 'Câmpul :attribute trebuie să aibă cel mult :value caractere.',
+ ],
+ 'mac_address' => 'Câmpul :attribute trebuie să fie o adresă MAC validă.',
+ 'max' => [
+ 'array' => 'Câmpul :attribute nu trebuie să aibă mai mult de :max elemente.',
+ 'file' => 'Câmpul :attribute nu trebuie să fie mai mare de :max kiloocteți.',
+ 'numeric' => 'Câmpul :attribute nu trebuie să fie mai mare de :max.',
+ 'string' => 'Câmpul :attribute nu trebuie să aibă mai mult de :max caractere.',
+ ],
+ 'max_digits' => 'Câmpul :attribute nu trebuie să aibă mai mult de :max cifre.',
+ 'mimes' => 'Câmpul :attribute trebuie să fie un fișier de tip: :values.',
+ 'mimetypes' => 'Câmpul :attribute trebuie să fie un fișier de tip: :values.',
+ 'min' => [
+ 'array' => 'Câmpul :attribute trebuie să aibă cel puțin :min elemente.',
+ 'file' => 'Câmpul :attribute trebuie să aibă cel puțin :min kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie cel puțin :min.',
+ 'string' => 'Câmpul :attribute trebuie să aibă cel puțin :min caractere.',
+ ],
+ 'min_digits' => 'Câmpul :attribute trebuie să aibă cel puțin :min cifre.',
+ 'missing' => 'Câmpul :attribute trebuie să lipsească.',
+ 'missing_if' => 'Câmpul :attribute trebuie să lipsească când :other este :value.',
+ 'missing_unless' => 'Câmpul :attribute trebuie să lipsească dacă :other nu este :value.',
+ 'missing_with' => 'Câmpul :attribute trebuie să lipsească când :values este prezent.',
+ 'missing_with_all' => 'Câmpul :attribute trebuie să lipsească când :values sunt prezente.',
+ 'multiple_of' => 'Câmpul :attribute trebuie să fie un multiplu de :value.',
+ 'not_in' => 'Valoarea selectată pentru :attribute este invalidă.',
+ 'not_regex' => 'Formatul câmpului :attribute este invalid.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie un număr.',
+ 'password' => [
+ 'letters' => 'Câmpul :attribute trebuie să conțină cel puțin o literă.',
+ 'mixed' => 'Câmpul :attribute trebuie să conțină cel puțin o literă mare și una mică.',
+ 'numbers' => 'Câmpul :attribute trebuie să conțină cel puțin o cifră.',
+ 'symbols' => 'Câmpul :attribute trebuie să conțină cel puțin un simbol.',
+ 'uncompromised' => 'Valoarea :attribute a apărut într-o scurgere de date. Te rugăm să alegi o altă valoare pentru :attribute.',
+ ],
+ 'present' => 'Câmpul :attribute trebuie să fie prezent.',
+ 'present_if' => 'Câmpul :attribute trebuie să fie prezent când :other este :value.',
+ 'present_unless' => 'Câmpul :attribute trebuie să fie prezent dacă :other nu este :value.',
+ 'present_with' => 'Câmpul :attribute trebuie să fie prezent când :values este prezent.',
+ 'present_with_all' => 'Câmpul :attribute trebuie să fie prezent când :values sunt prezente.',
+ 'prohibited' => 'Câmpul :attribute este interzis.',
+ 'prohibited_if' => 'Câmpul :attribute este interzis când :other este :value.',
+ 'prohibited_if_accepted' => 'Câmpul :attribute este interzis când :other este acceptat.',
+ 'prohibited_if_declined' => 'Câmpul :attribute este interzis când :other este refuzat.',
+ 'prohibited_unless' => 'Câmpul :attribute este interzis dacă :other nu este în :values.',
+ 'prohibits' => 'Câmpul :attribute interzice prezența :other.',
+ 'regex' => 'Formatul câmpului :attribute este invalid.',
+ 'required' => 'Câmpul :attribute este obligatoriu.',
+ 'required_array_keys' => 'Câmpul :attribute trebuie să conțină intrări pentru: :values.',
+ 'required_if' => 'Câmpul :attribute este obligatoriu când :other este :value.',
+ 'required_if_accepted' => 'Câmpul :attribute este obligatoriu când :other este acceptat.',
+ 'required_if_declined' => 'Câmpul :attribute este obligatoriu când :other este refuzat.',
+ 'required_unless' => 'Câmpul :attribute este obligatoriu dacă :other nu este în :values.',
+ 'required_with' => 'Câmpul :attribute este obligatoriu când :values este prezent.',
+ 'required_with_all' => 'Câmpul :attribute este obligatoriu când :values sunt prezente.',
+ 'required_without' => 'Câmpul :attribute este obligatoriu când :values nu este prezent.',
+ 'required_without_all' => 'Câmpul :attribute este obligatoriu când niciuna dintre :values nu este prezentă.',
+ 'same' => 'Câmpul :attribute trebuie să corespundă cu :other.',
+ 'size' => [
+ 'array' => 'Câmpul :attribute trebuie să conțină :size elemente.',
+ 'file' => 'Câmpul :attribute trebuie să aibă :size kiloocteți.',
+ 'numeric' => 'Câmpul :attribute trebuie să fie :size.',
+ 'string' => 'Câmpul :attribute trebuie să aibă :size caractere.',
+ ],
+ 'starts_with' => 'Câmpul :attribute trebuie să înceapă cu una dintre următoarele: :values.',
+ 'string' => 'Câmpul :attribute trebuie să fie un șir de caractere.',
+ 'timezone' => 'Câmpul :attribute trebuie să fie un fus orar valid.',
+ 'unique' => 'Valoarea :attribute este deja folosită.',
+ 'uploaded' => 'Încărcarea :attribute a eșuat.',
+ 'uppercase' => 'Câmpul :attribute trebuie să fie cu litere mari.',
+ 'url' => 'Câmpul :attribute trebuie să fie o adresă URL validă.',
+ 'ulid' => 'Câmpul :attribute trebuie să fie un ULID valid.',
+ 'uuid' => 'Câmpul :attribute trebuie să fie un UUID valid.',
+
+ 'custom' => [
+ 'file' => [
+ 'required' => 'Este necesar un fișier PDF.',
+ 'mimes' => 'Fișierul trebuie să fie un document PDF.',
+ 'max' => 'Fișierul PDF nu trebuie să depășească 100 MB.',
+ ],
+ ],
+
+ 'attributes' => [
+ 'name' => 'nume',
+ 'email' => 'adresă de e-mail',
+ 'new_email' => 'adresă de e-mail nouă',
+ 'password' => 'parolă',
+ 'current_password' => 'parola curentă',
+ 'new_password' => 'parola nouă',
+ 'file' => 'fișier',
+ ],
+
+];
diff --git a/package.json b/package.json
index 7686b29..a127fc7 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"build": "vite build",
- "dev": "vite"
+ "dev": "vite",
+ "i18n:translate": "node scripts/translate-i18n.mjs"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
diff --git a/resources/views/emails/password-changed.blade.php b/resources/views/emails/password-changed.blade.php
index 8fa2cf7..7d1eba9 100644
--- a/resources/views/emails/password-changed.blade.php
+++ b/resources/views/emails/password-changed.blade.php
@@ -21,15 +21,15 @@
- Password changed, {{ $user->name }}
+ {{ __('emails.changed.title', ['name' => $user->name]) }}
- Your WordKeep password was just changed. If you made this change, no further action is needed.
+ {{ __('emails.changed.intro') }}
- If you did not change your password, please reset it immediately using the "Forgot password?" link on the login page.
+ {{ __('emails.changed.warning') }}
@@ -38,7 +38,7 @@
- This is an automated security notification from WordKeep.
+ {{ __('emails.common.security_footer') }}
diff --git a/resources/views/emails/password-reset.blade.php b/resources/views/emails/password-reset.blade.php
index 03040d8..d6a37fe 100644
--- a/resources/views/emails/password-reset.blade.php
+++ b/resources/views/emails/password-reset.blade.php
@@ -21,11 +21,11 @@
- Reset Your Password
+ {{ __('emails.reset.title') }}
- Hi {{ $user->name }}, we received a request to reset your WordKeep password. Click the button below to choose a new password.
+ {{ __('emails.reset.intro', ['name' => $user->name]) }}
@@ -33,20 +33,20 @@
- Reset Password
+ {{ __('emails.reset.button') }}
- This link will expire in 60 minutes .
+ {!! __('emails.reset.expiry', ['minutes' => '60 ']) !!}
- If the button doesn't work, copy and paste this link into your browser:
+ {{ __('emails.common.fallback') }}
{{ $resetUrl }}
@@ -58,7 +58,7 @@
- If you didn't request a password reset, you can safely ignore this email.
+ {{ __('emails.reset.ignore') }}
diff --git a/resources/views/emails/two-factor-disabled.blade.php b/resources/views/emails/two-factor-disabled.blade.php
index e9feee1..3b16c7f 100644
--- a/resources/views/emails/two-factor-disabled.blade.php
+++ b/resources/views/emails/two-factor-disabled.blade.php
@@ -21,15 +21,15 @@
- Two-factor authentication disabled, {{ $user->name }}
+ {{ __('emails.two_factor_disabled.title', ['name' => $user->name]) }}
- Two-factor authentication has been disabled on your WordKeep account. You will no longer be asked for a code when signing in.
+ {{ __('emails.two_factor_disabled.intro') }}
- If you did not make this change, your account may be compromised. Please change your password immediately and re-enable two-factor authentication.
+ {{ __('emails.two_factor_disabled.warning') }}
@@ -38,7 +38,7 @@
- This is an automated security notification from WordKeep.
+ {{ __('emails.common.security_footer') }}
diff --git a/resources/views/emails/two-factor-enabled.blade.php b/resources/views/emails/two-factor-enabled.blade.php
index 59444cf..28274f8 100644
--- a/resources/views/emails/two-factor-enabled.blade.php
+++ b/resources/views/emails/two-factor-enabled.blade.php
@@ -21,19 +21,19 @@
- Two-factor authentication enabled, {{ $user->name }}
+ {{ __('emails.two_factor_enabled.title', ['name' => $user->name]) }}
- Two-factor authentication has been enabled on your WordKeep account. You will now be asked for a 6-digit code from your authenticator app each time you sign in.
+ {{ __('emails.two_factor_enabled.intro') }}
- Make sure to keep your recovery codes in a safe place — they are the only way to access your account if you lose your authenticator device.
+ {{ __('emails.two_factor_enabled.recovery') }}
- If you did not make this change, please contact us immediately.
+ {{ __('emails.two_factor_enabled.warning') }}
@@ -42,7 +42,7 @@
- This is an automated security notification from WordKeep.
+ {{ __('emails.common.security_footer') }}
diff --git a/resources/views/emails/verify-email-change.blade.php b/resources/views/emails/verify-email-change.blade.php
index d67ed75..89f37fb 100644
--- a/resources/views/emails/verify-email-change.blade.php
+++ b/resources/views/emails/verify-email-change.blade.php
@@ -21,11 +21,11 @@
- Confirm your new email, {{ $user->name }}
+ {{ __('emails.verify_change.title', ['name' => $user->name]) }}
- You requested to change your WordKeep email address to this one. Click the button below to confirm the change.
+ {{ __('emails.verify_change.intro') }}
@@ -33,20 +33,20 @@
- Confirm New Email
+ {{ __('emails.verify_change.button') }}
- This link will expire in 5 days . If not confirmed, your email address will remain unchanged.
+ {!! __('emails.verify_change.expiry', ['days' => '5 ']) !!}
- If the button doesn't work, copy and paste this link into your browser:
+ {{ __('emails.common.fallback') }}
{{ $verificationUrl }}
@@ -58,7 +58,7 @@
- If you didn't request this email change, you can safely ignore this email. Your current email address remains active.
+ {{ __('emails.verify_change.ignore') }}
diff --git a/resources/views/emails/verify-email.blade.php b/resources/views/emails/verify-email.blade.php
index d452b86..2a1d1da 100644
--- a/resources/views/emails/verify-email.blade.php
+++ b/resources/views/emails/verify-email.blade.php
@@ -21,11 +21,11 @@
- Welcome, {{ $user->name }}!
+ {{ __('emails.verify.title', ['name' => $user->name]) }}
- Thanks for signing up for WordKeep. To complete your registration and activate your account, please verify your email address by clicking the button below.
+ {{ __('emails.verify.intro') }}
@@ -33,20 +33,20 @@
- Verify Email Address
+ {{ __('emails.verify.button') }}
- This link will expire in 5 days . If you don't verify your email within this time, your account will be automatically deleted.
+ {!! __('emails.verify.expiry', ['days' => '5 ']) !!}
- If the button doesn't work, copy and paste this link into your browser:
+ {{ __('emails.common.fallback') }}
{{ $verificationUrl }}
@@ -58,7 +58,7 @@
- If you didn't create an account with WordKeep, you can safely ignore this email.
+ {{ __('emails.verify.ignore') }}
diff --git a/routes/api.php b/routes/api.php
index 2525de4..270195d 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -36,6 +36,10 @@
Route::post('verify-email-change', [ProfileController::class, 'verifyEmailChange']);
Route::post('login/2fa', [AuthController::class, 'loginWithTwoFactor'])->middleware('throttle:5,1');
+// Translation pricing config (public: static price list, no user data — lets the
+// guest demo read live prices so they never drift from the real value).
+Route::get('translation/pricing', [TranslationController::class, 'pricing'])->middleware('throttle:30,1');
+
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
// Auth routes
@@ -131,9 +135,6 @@
Route::get('view-states', [UserViewStatesController::class, 'show']);
Route::patch('view-states', [UserViewStatesController::class, 'update']);
- // Translation pricing config
- Route::get('translation/pricing', [TranslationController::class, 'pricing']);
-
// User-level translation list
Route::get('translations', [TranslationController::class, 'userIndex']);
diff --git a/scripts/translate-i18n.mjs b/scripts/translate-i18n.mjs
new file mode 100644
index 0000000..ab6db34
--- /dev/null
+++ b/scripts/translate-i18n.mjs
@@ -0,0 +1,306 @@
+/**
+ * Seeds / updates .json translation files from their English (en.json)
+ * siblings using Google Gemini 2.5 Flash. The target language is a required CLI
+ * argument (its locale code, e.g. ro, fr) — see LANG_NAMES for supported codes.
+ *
+ * For every en.json under wordkeep-client/public/assets/i18n/ (the global file
+ * and each lazy-scope folder), it:
+ * 1. Loads en.json and the existing .json (if any).
+ * 2. Finds keys present in en but MISSING from (merge mode — reviewed
+ * translations are never overwritten).
+ * 3. Asks Gemini to translate only those values, preserving JSON shape,
+ * {{placeholders}} and adapting ICU plural categories to the target language.
+ * 4. Validates placeholder parity, then writes a merged .json whose key
+ * order mirrors en.json.
+ *
+ * Usage (from repo root):
+ * node scripts/translate-i18n.mjs # translate missing keys only
+ * node scripts/translate-i18n.mjs --all # retranslate every key (overwrite)
+ * node scripts/translate-i18n.mjs --dry-run # report what would change, write nothing
+ *
+ * must be one of the codes in the LANG_NAMES map below.
+ * Requires GEMINI_API_KEY (read from the repo .env or the environment).
+ * Review the generated .json diff and fix terminology before committing.
+ */
+
+import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(__dirname, '..');
+const i18nDir = join(repoRoot, 'wordkeep-client/public/assets/i18n');
+
+// ── supported target languages (code → English name) ────────────────
+// Self-contained so the script doesn't depend on the client's i18n assets.
+const LANG_NAMES = {
+ ar: 'Arabic', bg: 'Bulgarian', zh: 'Chinese', hr: 'Croatian', cs: 'Czech',
+ da: 'Danish', nl: 'Dutch', fi: 'Finnish', fr: 'French', de: 'German',
+ el: 'Greek', he: 'Hebrew', hi: 'Hindi', hu: 'Hungarian', id: 'Indonesian',
+ it: 'Italian', ja: 'Japanese', ko: 'Korean', la: 'Latin', ms: 'Malay',
+ no: 'Norwegian', pl: 'Polish', pt: 'Portuguese', ro: 'Romanian', ru: 'Russian',
+ sr: 'Serbian', sk: 'Slovak', sl: 'Slovenian', es: 'Spanish', sv: 'Swedish',
+ th: 'Thai', tr: 'Turkish', uk: 'Ukrainian', vi: 'Vietnamese',
+};
+
+// ── CLI args ─────────────────────────────────────────────────────────
+const argv = process.argv.slice(2);
+const RETRANSLATE_ALL = argv.includes('--all');
+const DRY_RUN = argv.includes('--dry-run');
+const TARGET = argv.find(a => !a.startsWith('--'));
+const TARGET_NAME = TARGET ? LANG_NAMES[TARGET] : undefined;
+
+if (!TARGET_NAME || TARGET === 'en') {
+ const reason = !TARGET
+ ? 'No target language given.'
+ : `Unsupported language code "${TARGET}".`;
+ console.error(`✗ ${reason}`);
+ console.error('\nUsage: node scripts/translate-i18n.mjs [--all] [--dry-run]');
+ console.error(`Supported codes: ${Object.keys(LANG_NAMES).sort().join(', ')}`);
+ process.exit(1);
+}
+
+// ── env ──────────────────────────────────────────────────────────────
+function loadEnv() {
+ const env = { ...process.env };
+ const envPath = join(repoRoot, '.env');
+ if (existsSync(envPath)) {
+ for (const line of readFileSync(envPath, 'utf8').split('\n')) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+ if (m && env[m[1]] === undefined) {
+ env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+ }
+ }
+ }
+ return env;
+}
+
+const env = loadEnv();
+const API_KEY = env.GEMINI_API_KEY;
+const MODEL = env.GEMINI_MODEL || 'gemini-2.5-flash';
+const BASE_URL = env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta';
+
+// Transient-failure retry config (Gemini 429/5xx + network blips). Override via env.
+const MAX_RETRIES = Number(env.GEMINI_MAX_RETRIES) || 5;
+const RETRY_BASE_MS = Number(env.GEMINI_RETRY_BASE_MS) || 2000;
+const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+if (!API_KEY && !DRY_RUN) {
+ console.error('✗ GEMINI_API_KEY is not set (checked .env and environment).');
+ process.exit(1);
+}
+
+// ── flatten / unflatten nested JSON to dot-keyed leaves ──────────────
+function flatten(obj, prefix = '', out = {}) {
+ for (const [k, v] of Object.entries(obj)) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
+ else out[key] = v;
+ }
+ return out;
+}
+
+function setDeep(obj, dotKey, value) {
+ const parts = dotKey.split('.');
+ let node = obj;
+ for (let i = 0; i < parts.length - 1; i++) {
+ node[parts[i]] ??= {};
+ node = node[parts[i]];
+ }
+ node[parts[parts.length - 1]] = value;
+}
+
+/** Rebuild a nested object mirroring en's key order, pulling values from a flat map. */
+function rebuildLike(enObj, flatValues, prefix = '') {
+ const out = Array.isArray(enObj) ? [] : {};
+ for (const [k, v] of Object.entries(enObj)) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
+ out[k] = rebuildLike(v, flatValues, key);
+ } else {
+ out[k] = flatValues[key] ?? v;
+ }
+ }
+ return out;
+}
+
+// ── placeholder parity check ─────────────────────────────────────────
+// Only the invariants that must hold across languages are compared: the
+// {{...}} interpolations and the ICU argument names (the `{name, plural|select`
+// heads). Plural keyword sets and sub-message bodies legitimately differ per
+// language, so they are intentionally excluded.
+function placeholders(s) {
+ const str = String(s);
+ const interpolations = (str.match(/\{\{\s*[^{}]+?\s*\}\}/g) || []).map(t => t.replace(/\s+/g, ''));
+ const icuArgs = (str.match(/\{\s*([A-Za-z0-9_]+)\s*,\s*(?:plural|select|selectordinal)\b/g) || [])
+ .map(t => t.replace(/\s+/g, ''));
+ return [...interpolations, ...icuArgs].sort();
+}
+function samePlaceholders(a, b) {
+ const pa = placeholders(a), pb = placeholders(b);
+ return pa.length === pb.length && pa.every((t, i) => t === pb[i]);
+}
+
+// ── Gemini call ──────────────────────────────────────────────────────
+async function translateBatch(pairs) {
+ // pairs: { dotKey: englishValue }
+ const prompt = [
+ `You are translating UI strings for the WordKeep app from English to ${TARGET_NAME}.`,
+ 'Translate ONLY the string values of the JSON object below. Keep every key exactly as-is.',
+ 'Rules:',
+ '- Preserve ALL placeholders verbatim, e.g. {{name}}, {{count}} — do not translate or reorder them.',
+ `- Adapt ICU plural/select categories to ${TARGET_NAME}'s CLDR rules: add, rename or remove`,
+ ' categories (zero, one, two, few, many, other) as the language requires, while keeping the',
+ ' {var, plural, ...} / {var, select, ...} structure and the variable name unchanged.',
+ '- Preserve any inline HTML tags (e.g. , ) and HTML entities (e.g. —).',
+ `- Use natural, concise ${TARGET_NAME} appropriate for a literary document-keeping app.`,
+ '- Do NOT translate proper nouns: WordKeep, Sibiu, roman numerals (MMXXVI).',
+ `Return ONLY a JSON object mapping the same keys to the translated ${TARGET_NAME} strings.`,
+ '',
+ JSON.stringify(pairs, null, 2),
+ ].join('\n');
+
+ return requestWithRetry({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature: 0.2, responseMimeType: 'application/json' },
+ });
+}
+
+/**
+ * POST to Gemini and return the parsed translation map, retrying transient
+ * failures with exponential backoff so a temporary problem doesn't skip the
+ * whole file. Retryable: 429/5xx, network errors, and a malformed/truncated
+ * response body (empty candidate or unparseable JSON — common under load).
+ * Honors a Retry-After header when present. Non-transient errors throw at once.
+ */
+async function requestWithRetry(body) {
+ const url = `${BASE_URL}/models/${MODEL}:generateContent?key=${API_KEY}`;
+ let attempt = 0;
+ for (;;) {
+ let res;
+ try {
+ res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } catch (e) {
+ // Network-level failure (DNS, socket reset, timeout): retryable.
+ if (attempt >= MAX_RETRIES) throw new Error(`Gemini network error after ${attempt} retries: ${e.message}`);
+ await backoff(attempt++, `network error (${e.message})`);
+ continue;
+ }
+
+ if (!res.ok) {
+ const detail = await res.text();
+ if (!RETRY_STATUSES.has(res.status) || attempt >= MAX_RETRIES) {
+ throw new Error(`Gemini HTTP ${res.status}: ${detail}`);
+ }
+ const retryAfter = Number(res.headers.get('retry-after')) * 1000 || 0;
+ await backoff(attempt++, `HTTP ${res.status}`, retryAfter);
+ continue;
+ }
+
+ // 2xx but the body can still be empty or truncated JSON — treat as retryable.
+ try {
+ const data = await res.json();
+ const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
+ if (!text) throw new Error('empty candidate text');
+ return JSON.parse(text);
+ } catch (e) {
+ if (attempt >= MAX_RETRIES) throw new Error(`Gemini unparseable response after ${attempt} retries: ${e.message}`);
+ await backoff(attempt++, `bad response (${e.message})`);
+ }
+ }
+}
+
+/** Wait before the next attempt: max(Retry-After, exponential base) + jitter. */
+async function backoff(attempt, why, retryAfterMs = 0) {
+ const expo = RETRY_BASE_MS * 2 ** attempt;
+ const jitter = Math.floor(Math.random() * RETRY_BASE_MS);
+ const wait = Math.max(retryAfterMs, expo) + jitter;
+ console.warn(` ⟳ ${why}; retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(wait / 1000)}s`);
+ await sleep(wait);
+}
+
+// ── per-file processing ──────────────────────────────────────────────
+function findEnFiles() {
+ const files = [];
+ const rootEn = join(i18nDir, 'en.json');
+ if (existsSync(rootEn)) files.push(rootEn);
+ for (const entry of readdirSync(i18nDir, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ const scopeEn = join(i18nDir, entry.name, 'en.json');
+ if (existsSync(scopeEn)) files.push(scopeEn);
+ }
+ }
+ return files;
+}
+
+async function processFile(enPath) {
+ const targetPath = enPath.replace(/en\.json$/, `${TARGET}.json`);
+ const rel = enPath.slice(i18nDir.length + 1);
+ const en = JSON.parse(readFileSync(enPath, 'utf8'));
+ const existing = existsSync(targetPath) ? JSON.parse(readFileSync(targetPath, 'utf8')) : {};
+
+ const enFlat = flatten(en);
+ const targetFlat = flatten(existing);
+
+ const todo = {};
+ for (const [k, v] of Object.entries(enFlat)) {
+ if (typeof v !== 'string') continue;
+ if (RETRANSLATE_ALL || !(k in targetFlat) || targetFlat[k] === undefined || targetFlat[k] === '') {
+ todo[k] = v;
+ }
+ }
+
+ const todoCount = Object.keys(todo).length;
+ if (todoCount === 0) {
+ console.log(`• ${rel}: up to date`);
+ return;
+ }
+ console.log(`• ${rel}: ${todoCount} key(s) to translate${DRY_RUN ? ' (dry-run)' : ''}`);
+ if (DRY_RUN) {
+ for (const k of Object.keys(todo)) console.log(` + ${k}`);
+ return;
+ }
+
+ const translated = await translateBatch(todo);
+
+ // Start from existing target (reviewed values kept), apply translations.
+ const mergedFlat = { ...targetFlat };
+ for (const [k, en] of Object.entries(todo)) {
+ const t = translated[k];
+ if (typeof t !== 'string') {
+ console.warn(` ! ${k}: missing in Gemini response — keeping English`);
+ mergedFlat[k] = en;
+ continue;
+ }
+ if (!samePlaceholders(en, t)) {
+ console.warn(` ! ${k}: placeholder mismatch — keeping English ("${t}")`);
+ mergedFlat[k] = en;
+ continue;
+ }
+ mergedFlat[k] = t;
+ }
+
+ const merged = rebuildLike(en, mergedFlat);
+ writeFileSync(targetPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
+ console.log(` → wrote ${rel.replace(/en\.json$/, `${TARGET}.json`)}`);
+}
+
+// ── main ─────────────────────────────────────────────────────────────
+const enFiles = findEnFiles();
+console.log(`Translating to ${TARGET_NAME} (${TARGET}).`);
+console.log(`Found ${enFiles.length} en.json file(s) under ${i18nDir}\n`);
+for (const f of enFiles) {
+ try {
+ await processFile(f);
+ } catch (e) {
+ console.error(`✗ ${f.slice(i18nDir.length + 1)}: ${e.message}`);
+ process.exitCode = 1;
+ }
+}
+console.log(`\nDone. Review the ${TARGET}.json diffs and fix terminology before committing.`);
diff --git a/tests/Feature/BulkTranslateDocumentJobTest.php b/tests/Feature/BulkTranslateDocumentJobTest.php
index d0714d9..f9e7eb6 100644
--- a/tests/Feature/BulkTranslateDocumentJobTest.php
+++ b/tests/Feature/BulkTranslateDocumentJobTest.php
@@ -154,7 +154,10 @@ public function test_job_retranslates_completed_pages_when_override_true(): void
public function test_job_sets_page_linking_incomplete_flag_when_pages_unlinked(): void
{
- $document = $this->makeDocument();
+ // page_count > 1 so the DocumentPage `creating` hook doesn't treat the
+ // lone page 1 as the last page and auto-stamp DOCUMENT_END (which would
+ // make link_to_next_page non-null and flake this assertion).
+ $document = $this->makeDocument(['page_count' => 2]);
// Page with null link_to_next_page = unlinked
DocumentPage::factory()->completed('Text.')->create([
'document_id' => $document->id,
diff --git a/tests/Feature/LocalizationTest.php b/tests/Feature/LocalizationTest.php
new file mode 100644
index 0000000..3bab99f
--- /dev/null
+++ b/tests/Feature/LocalizationTest.php
@@ -0,0 +1,100 @@
+create([
+ 'password' => Hash::make('correct-password'),
+ 'email_verified_at' => now(),
+ ]);
+
+ $this->withHeader('Accept-Language', 'ro')
+ ->postJson('/api/login', [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.email.0', 'Aceste date de autentificare nu corespund înregistrărilor noastre.');
+ }
+
+ public function test_validation_errors_default_to_english(): void
+ {
+ $user = User::factory()->create([
+ 'password' => Hash::make('correct-password'),
+ 'email_verified_at' => now(),
+ ]);
+
+ $this->postJson('/api/login', [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.email.0', 'These credentials do not match our records.');
+ }
+
+ public function test_unsupported_accept_language_falls_back_to_english(): void
+ {
+ $user = User::factory()->create([
+ 'password' => Hash::make('correct-password'),
+ 'email_verified_at' => now(),
+ ]);
+
+ $this->withHeader('Accept-Language', 'de-DE,de;q=0.9')
+ ->postJson('/api/login', [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.email.0', 'These credentials do not match our records.');
+ }
+
+ public function test_accept_language_with_region_and_quality_resolves_primary_tag(): void
+ {
+ $user = User::factory()->create([
+ 'password' => Hash::make('correct-password'),
+ 'email_verified_at' => now(),
+ ]);
+
+ $this->withHeader('Accept-Language', 'ro-RO,ro;q=0.9,en;q=0.8')
+ ->postJson('/api/login', [
+ 'email' => $user->email,
+ 'password' => 'wrong-password',
+ ])
+ ->assertStatus(422)
+ ->assertJsonPath('errors.email.0', 'Aceste date de autentificare nu corespund înregistrărilor noastre.');
+ }
+
+ public function test_transactional_email_renders_in_romanian(): void
+ {
+ $user = User::factory()->create(['name' => 'Ana']);
+
+ App::setLocale('ro');
+ $mailable = new VerifyEmail($user, 'https://wordkeep.test/verify');
+
+ $this->assertSame('Confirmă-ți adresa de e-mail WordKeep', $mailable->envelope()->subject);
+ $this->assertStringContainsString('Bun venit, Ana!', $mailable->render());
+ }
+
+ public function test_transactional_email_renders_in_english_by_default(): void
+ {
+ $user = User::factory()->create(['name' => 'John']);
+
+ App::setLocale('en');
+ $mailable = new VerifyEmail($user, 'https://wordkeep.test/verify');
+
+ $this->assertSame('Verify Your WordKeep Email', $mailable->envelope()->subject);
+ $this->assertStringContainsString('Welcome, John!', $mailable->render());
+ }
+}
diff --git a/tests/Feature/TranslationControllerTest.php b/tests/Feature/TranslationControllerTest.php
index bc7144b..eafea74 100644
--- a/tests/Feature/TranslationControllerTest.php
+++ b/tests/Feature/TranslationControllerTest.php
@@ -118,6 +118,151 @@ public function test_user_can_translate_page(): void
]);
}
+ public function test_translating_pages_individually_marks_the_summary_completed(): void
+ {
+ $user = User::factory()->create();
+ $document = Document::factory()->create(['user_id' => $user->id]);
+ $page = DocumentPage::factory()->completed('Hello world.')->create([
+ 'document_id' => $document->id,
+ 'page_number' => 1,
+ 'link_to_next_page' => 'DOCUMENT_END',
+ 'link_to_previous_page' => null,
+ ]);
+
+ // Simulate the service persisting a completed page translation.
+ $translation = DocumentPageTranslation::factory()->completed('Bonjour monde.')->create([
+ 'document_page_id' => $page->id,
+ 'target_language' => 'fr',
+ ]);
+
+ $mockService = Mockery::mock(TranslationService::class);
+ $mockService->shouldReceive('translatePage')->once()->andReturn([$translation, false]);
+ $this->app->instance(TranslationService::class, $mockService);
+
+ $this->actingAs($user)
+ ->postJson("/api/documents/{$document->id}/pages/1/translation", ['target_language' => 'fr'])
+ ->assertOk();
+
+ // The document-level summary must reflect the per-page work (not linger at pending 0/0).
+ $this->assertDatabaseHas('document_translations', [
+ 'document_id' => $document->id,
+ 'target_language' => 'fr',
+ 'status' => 'completed',
+ 'pages_total' => 1,
+ 'pages_completed' => 1,
+ ]);
+ }
+
+ public function test_deleting_a_page_translation_downgrades_the_summary(): void
+ {
+ $user = User::factory()->create();
+ $document = Document::factory()->create(['user_id' => $user->id]);
+ $page = DocumentPage::factory()->completed('Hello world.')->create([
+ 'document_id' => $document->id,
+ 'page_number' => 1,
+ 'link_to_next_page' => 'DOCUMENT_END',
+ 'link_to_previous_page' => null,
+ ]);
+ DocumentPageTranslation::factory()->completed('Bonjour monde.')->create([
+ 'document_page_id' => $page->id,
+ 'target_language' => 'fr',
+ ]);
+
+ $this->actingAs($user)
+ ->deleteJson("/api/documents/{$document->id}/pages/1/translation/fr")
+ ->assertOk();
+
+ $this->assertDatabaseHas('document_translations', [
+ 'document_id' => $document->id,
+ 'target_language' => 'fr',
+ 'status' => 'pending',
+ 'pages_total' => 1,
+ 'pages_completed' => 0,
+ ]);
+ }
+
+ public function test_translating_only_some_pages_leaves_the_summary_pending(): void
+ {
+ $user = User::factory()->create();
+ $document = Document::factory()->create(['user_id' => $user->id]);
+ $page1 = DocumentPage::factory()->completed('Page one.')->create([
+ 'document_id' => $document->id,
+ 'page_number' => 1,
+ 'link_to_next_page' => 'NEW_ROW',
+ 'link_to_previous_page' => null,
+ ]);
+ DocumentPage::factory()->completed('Page two.')->create([
+ 'document_id' => $document->id,
+ 'page_number' => 2,
+ 'link_to_next_page' => 'DOCUMENT_END',
+ 'link_to_previous_page' => null,
+ ]);
+
+ // Only page 1 gets a completed translation.
+ $translation = DocumentPageTranslation::factory()->completed('Pagina unu.')->create([
+ 'document_page_id' => $page1->id,
+ 'target_language' => 'ro',
+ ]);
+
+ $mockService = Mockery::mock(TranslationService::class);
+ $mockService->shouldReceive('translatePage')->once()->andReturn([$translation, false]);
+ $this->app->instance(TranslationService::class, $mockService);
+
+ $this->actingAs($user)
+ ->postJson("/api/documents/{$document->id}/pages/1/translation", ['target_language' => 'ro'])
+ ->assertOk();
+
+ $this->assertDatabaseHas('document_translations', [
+ 'document_id' => $document->id,
+ 'target_language' => 'ro',
+ 'status' => 'pending',
+ 'pages_total' => 2,
+ 'pages_completed' => 1,
+ ]);
+ }
+
+ public function test_per_page_translate_does_not_clobber_an_active_bulk_run(): void
+ {
+ $user = User::factory()->create();
+ $document = Document::factory()->create(['user_id' => $user->id]);
+ $page = DocumentPage::factory()->completed('Hello world.')->create([
+ 'document_id' => $document->id,
+ 'page_number' => 1,
+ 'link_to_next_page' => 'DOCUMENT_END',
+ 'link_to_previous_page' => null,
+ ]);
+
+ // A document-level bulk run currently owns the summary record.
+ DocumentTranslation::create([
+ 'document_id' => $document->id,
+ 'target_language' => 'ro',
+ 'status' => 'processing',
+ 'pages_total' => 1,
+ 'pages_completed' => 0,
+ ]);
+
+ $translation = DocumentPageTranslation::factory()->completed('Salut.')->create([
+ 'document_page_id' => $page->id,
+ 'target_language' => 'ro',
+ ]);
+
+ $mockService = Mockery::mock(TranslationService::class);
+ $mockService->shouldReceive('translatePage')->once()->andReturn([$translation, false]);
+ $this->app->instance(TranslationService::class, $mockService);
+
+ $this->actingAs($user)
+ ->postJson("/api/documents/{$document->id}/pages/1/translation", ['target_language' => 'ro'])
+ ->assertOk();
+
+ // The refresh must NOT touch a record an active bulk run owns.
+ $this->assertDatabaseHas('document_translations', [
+ 'document_id' => $document->id,
+ 'target_language' => 'ro',
+ 'status' => 'processing',
+ 'pages_completed' => 0,
+ ]);
+ }
+
public function test_translate_page_includes_page_linking_incomplete_true_when_links_null(): void
{
$user = User::factory()->create();
@@ -807,9 +952,13 @@ public function test_next_untranslated_page_returns_next_after_given_page(): voi
// --- pricing ---
- public function test_unauthenticated_user_cannot_get_pricing(): void
+ public function test_unauthenticated_user_can_get_pricing(): void
{
- $this->getJson('/api/translation/pricing')->assertStatus(401);
+ // Public, read-only price list (no user data) — the guest demo reads it so
+ // its cost dialog never drifts from the live value.
+ $this->getJson('/api/translation/pricing')
+ ->assertOk()
+ ->assertJsonStructure(['input_per_million_tokens', 'output_per_million_tokens', 'chars_per_token']);
}
public function test_pricing_returns_expected_structure(): void
@@ -821,8 +970,8 @@ public function test_pricing_returns_expected_structure(): void
->assertOk()
->assertJsonStructure(['input_per_million_tokens', 'output_per_million_tokens', 'chars_per_token'])
->assertJson([
- 'input_per_million_tokens' => 0.075,
- 'output_per_million_tokens' => 0.30,
+ 'input_per_million_tokens' => 0.30,
+ 'output_per_million_tokens' => 2.50,
'chars_per_token' => 4,
]);
}
diff --git a/tests/Feature/UserPreferencesControllerTest.php b/tests/Feature/UserPreferencesControllerTest.php
index dea2326..7fc011b 100644
--- a/tests/Feature/UserPreferencesControllerTest.php
+++ b/tests/Feature/UserPreferencesControllerTest.php
@@ -138,6 +138,42 @@ public function test_update_rejects_invalid_layout(): void
->assertJsonValidationErrors(['layout']);
}
+ public function test_update_persists_locale(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->patchJson('/api/preferences', ['locale' => 'ro'])
+ ->assertOk()
+ ->assertJsonPath('data.locale', 'ro');
+
+ $this->assertDatabaseHas('user_preferences', [
+ 'user_id' => $user->id,
+ 'locale' => 'ro',
+ ]);
+ }
+
+ public function test_update_rejects_invalid_locale(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->patchJson('/api/preferences', ['locale' => 'fr'])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['locale']);
+ }
+
+ public function test_show_returns_null_locale_when_unset(): void
+ {
+ $user = User::factory()->create();
+ UserPreference::create(['user_id' => $user->id, 'theme_mode' => 'dark']);
+
+ $this->actingAs($user)
+ ->getJson('/api/preferences')
+ ->assertOk()
+ ->assertJsonPath('data.locale', null);
+ }
+
public function test_update_accepts_empty_payload(): void
{
$user = User::factory()->create();
diff --git a/wordkeep-client/e2e/demo.spec.ts b/wordkeep-client/e2e/demo.spec.ts
new file mode 100644
index 0000000..fefeca6
--- /dev/null
+++ b/wordkeep-client/e2e/demo.spec.ts
@@ -0,0 +1,91 @@
+import { test, expect, Page } from '@playwright/test';
+
+/**
+ * Guest demo (/demo) — runs entirely against the Angular client with NO backend:
+ * every reader's data service is overridden with a canned in-memory version, so
+ * the tour must never touch /api. The sole allowed exception is the public,
+ * read-only GET /api/translation/pricing (so the cost dialog never drifts from
+ * the live price) — the tracker ignores that one URL. These specs use the plain
+ * @playwright/test import (no auth fixture) — the guest is never logged in.
+ */
+
+/** The one /api endpoint the demo is allowed to hit (public price list). */
+const ALLOWED_API = '/api/translation/pricing';
+
+/** Record any /api request (except the allowed pricing GET) so we can assert none. */
+function trackApiCalls(page: Page): string[] {
+ const calls: string[] = [];
+ page.on('request', r => {
+ const url = r.url();
+ if (url.includes('/api/') && !url.includes(ALLOWED_API)) calls.push(`${r.method()} ${url}`);
+ });
+ return calls;
+}
+
+test.describe('guest demo (no backend, zero /api)', () => {
+ test('the "Witness the craft" CTA enters the demo at the extraction stage', async ({ page }) => {
+ const api = trackApiCalls(page);
+ await page.goto('/');
+ await page.locator('a.cta--demo[href="/demo"]').click();
+ await expect(page).toHaveURL(/\/demo\/extraction\/1/);
+ expect(api, `unexpected /api calls: ${api.join(', ')}`).toEqual([]);
+ });
+
+ test('an Article Latin link deep-links into its matching stage', async ({ page }) => {
+ await page.goto('/');
+ await page.locator('a.article__demo[href="/demo/translation/1/ro"]').click();
+ await expect(page).toHaveURL(/\/demo\/translation\/1\/ro/);
+ });
+
+ test('walks all five stages with working reveals and zero /api calls', async ({ page }) => {
+ const api = trackApiCalls(page);
+ await page.goto('/demo/extraction/1');
+
+ // Stage 1 — extraction: run "AI OCR", the canned text reveals after the faux delay.
+ await page.locator('.btn-primary-sm').first().click();
+ await expect(page.locator('.ocr-text.markdown-content').first())
+ .toContainText('A NOVEL WITHOUT A HERO', { timeout: 8000 });
+
+ const proceed = page.locator('.demo-cta--primary');
+
+ // Stage 2 — page linking: make a (throwaway) decision.
+ await proceed.click();
+ await expect(page).toHaveURL(/\/demo\/page-linking\/1/);
+ await page.locator('.btn-toggle').first().click();
+
+ // Stage 3 — book view (original).
+ await proceed.click();
+ await expect(page).toHaveURL(/\/demo\/book-view\/1(\?|$)/);
+
+ // Stage 4 — translation: run it. The real cost confirmation dialog appears
+ // (a deliberate part of the demo) — confirm it, then the empty state gives
+ // way to the rendered translation.
+ await proceed.click();
+ await expect(page).toHaveURL(/\/demo\/translation\/1\/ro/);
+ await expect(page.locator('.ocr-empty')).toBeVisible();
+ await page.locator('.btn-primary-sm').first().click();
+ await page.locator('.dialog-card .btn-confirm').click();
+ await expect(page.locator('.ocr-empty')).toHaveCount(0, { timeout: 8000 });
+
+ // Stage 5 — translated book view.
+ await proceed.click();
+ await expect(page).toHaveURL(/lang=ro/);
+
+ expect(api, `unexpected /api calls: ${api.join(', ')}`).toEqual([]);
+ });
+
+ test('hides the in-reader back link; the footer Back exits to the landing page', async ({ page }) => {
+ await page.goto('/demo/extraction/1');
+ await expect(page.locator('.btn-back-link')).toHaveCount(0);
+ await page.locator('.demo-cta--ghost').click(); // Back on the first stage
+ await expect(page).toHaveURL('/');
+ });
+
+ test('switching a colour palette re-themes the app with no /api call', async ({ page }) => {
+ const api = trackApiCalls(page);
+ await page.goto('/demo/extraction/1');
+ await page.locator('.demo-swatch').nth(1).click(); // 2nd palette = cobalt
+ await expect(page.locator('html')).toHaveAttribute('data-palette', 'cobalt');
+ expect(api, `unexpected /api calls: ${api.join(', ')}`).toEqual([]);
+ });
+});
diff --git a/wordkeep-client/e2e/landing.spec.ts b/wordkeep-client/e2e/landing.spec.ts
index 9acd167..67e5b91 100644
--- a/wordkeep-client/e2e/landing.spec.ts
+++ b/wordkeep-client/e2e/landing.spec.ts
@@ -24,6 +24,26 @@ test.describe('landing (guest)', () => {
await expect(page).toHaveURL('/login');
});
+ test('language pennants toggle the charter living language and persist it', async ({ page }) => {
+ await page.goto('/');
+ const en = page.locator('.charter__flags .pennant--en');
+ const ro = page.locator('.charter__flags .pennant--ro');
+ await expect(en).toHaveAttribute('aria-pressed', 'true');
+
+ await ro.click();
+ await expect(ro).toHaveAttribute('aria-pressed', 'true');
+
+ // The English layer now carries Romanian copy and lang="ro".
+ const roLayer = page.locator('main.charter .charter__opening > .translatable__en');
+ await expect(roLayer).toHaveAttribute('lang', 'ro');
+
+ expect(await page.evaluate(() => localStorage.getItem('wk:locale'))).toBe('ro');
+
+ // Choice survives a reload (read back from localStorage pre-login).
+ await page.reload();
+ await expect(page.locator('.charter__flags .pennant--ro')).toHaveAttribute('aria-pressed', 'true');
+ });
+
test('hovering a Latin block fades Latin out and English in', async ({ page }) => {
await page.goto('/');
const block = page.locator('main.charter .charter__opening.translatable');
diff --git a/wordkeep-client/e2e/locale-switch.spec.ts b/wordkeep-client/e2e/locale-switch.spec.ts
new file mode 100644
index 0000000..9b03add
--- /dev/null
+++ b/wordkeep-client/e2e/locale-switch.spec.ts
@@ -0,0 +1,34 @@
+import { test, expect } from './fixtures';
+
+// The locale switcher on the Personalisation page is the in-app entry point for
+// changing language while signed in. Switching persists to the server (and
+// localStorage), and localized UI strings update immediately.
+test.describe('locale switching (signed in)', () => {
+ test.afterEach(async ({ page }) => {
+ // Leave the account back on English so other specs are unaffected.
+ await page.goto('/profile/personalisation');
+ await page.locator('.lang-button').first().click();
+ await expect(page.locator('.settings-section', { hasText: 'Language' })).toBeVisible();
+ });
+
+ test('switching to Romanian localizes the UI and persists across reload', async ({ page }) => {
+ await page.goto('/profile/personalisation');
+
+ // EN is active to begin with.
+ const enBtn = page.locator('.lang-button').first();
+ const roBtn = page.locator('.lang-button').nth(1);
+ await expect(enBtn).toHaveAttribute('aria-pressed', 'true');
+
+ await roBtn.click();
+ await expect(roBtn).toHaveAttribute('aria-pressed', 'true');
+
+ // The language section heading is now Romanian.
+ await expect(page.getByRole('heading', { name: 'Limbă' })).toBeVisible();
+ expect(await page.evaluate(() => localStorage.getItem('wk:locale'))).toBe('ro');
+
+ // Reload: the server-persisted choice is reapplied after login.
+ await page.reload();
+ await expect(page.getByRole('heading', { name: 'Limbă' })).toBeVisible();
+ await expect(page.locator('.lang-button').nth(1)).toHaveAttribute('aria-pressed', 'true');
+ });
+});
diff --git a/wordkeep-client/package-lock.json b/wordkeep-client/package-lock.json
index 21cd413..7051a96 100644
--- a/wordkeep-client/package-lock.json
+++ b/wordkeep-client/package-lock.json
@@ -14,6 +14,8 @@
"@angular/forms": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0",
+ "@jsverse/transloco": "^8.3.0",
+ "@jsverse/transloco-messageformat": "^8.3.0",
"@types/turndown": "^5.0.6",
"dictionary-de": "^3.0.0",
"dictionary-en": "^4.0.0",
@@ -34,7 +36,7 @@
"@angular/build": "^21.1.0",
"@angular/cli": "^21.1.0",
"@angular/compiler-cli": "^21.1.0",
- "@playwright/test": "^1.58.2",
+ "@playwright/test": "^1.60.0",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
@@ -676,7 +678,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
"integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
@@ -871,7 +872,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2078,6 +2078,56 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@jsverse/transloco": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-8.3.0.tgz",
+ "integrity": "sha512-p69/MhhAsTabDAffaiMALx4u9AwAqx24CjdECaCkUKSpCGbiYQUJPCsV4OsWjaSOxDNm9HuIX6NmqbfNZ0S3KA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jsverse/transloco-utils": "^8.2.1",
+ "@jsverse/utils": "1.0.0-beta.5",
+ "tslib": "^2.2.0"
+ },
+ "peerDependencies": {
+ "@angular/core": ">=16.0.0",
+ "rxjs": ">=6.0.0"
+ }
+ },
+ "node_modules/@jsverse/transloco-messageformat": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@jsverse/transloco-messageformat/-/transloco-messageformat-8.3.0.tgz",
+ "integrity": "sha512-Mz0OiYaNeMXFkRSWNEjOcEwqyOzi/v7D71tlaJDdWQagJqvFyw20DcHtv3xq5p+MvOLUjq7rtZYQiExlwo4NsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@messageformat/core": "^3.4.0",
+ "tslib": "^2.2.0"
+ },
+ "peerDependencies": {
+ "@angular/core": ">=16.0.0",
+ "@jsverse/transloco": ">=8.0.0",
+ "@jsverse/utils": "1.0.0-beta.5",
+ "rxjs": ">=6.0.0"
+ }
+ },
+ "node_modules/@jsverse/transloco-utils": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@jsverse/transloco-utils/-/transloco-utils-8.3.0.tgz",
+ "integrity": "sha512-HZPDKadKiL3l4iZ51PF7gv7txBgrR7gQB6crdfLSrXsCvCTGDnnhd3y5qO02pBWbWMDKRXKU0clv2dd3jeTSFA==",
+ "license": "MIT",
+ "dependencies": {
+ "cosmiconfig": "^8.1.3",
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jsverse/utils": {
+ "version": "1.0.0-beta.5",
+ "resolved": "https://registry.npmjs.org/@jsverse/utils/-/utils-1.0.0-beta.5.tgz",
+ "integrity": "sha512-z7IdlV6BdSeF3Veii8Yyk64KuyTjNIQnFaW5PAhmDx0wN29lB2BFp8WO6+tJPLPjtlz2yKeNrjkp1XqnMPaeHA==",
+ "license": "MIT"
+ },
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
@@ -2193,6 +2243,50 @@
"win32"
]
},
+ "node_modules/@messageformat/core": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz",
+ "integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@messageformat/date-skeleton": "^1.0.0",
+ "@messageformat/number-skeleton": "^1.0.0",
+ "@messageformat/parser": "^5.1.0",
+ "@messageformat/runtime": "^3.0.1",
+ "make-plural": "^7.0.0",
+ "safe-identifier": "^0.4.1"
+ }
+ },
+ "node_modules/@messageformat/date-skeleton": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz",
+ "integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==",
+ "license": "MIT"
+ },
+ "node_modules/@messageformat/number-skeleton": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz",
+ "integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==",
+ "license": "MIT"
+ },
+ "node_modules/@messageformat/parser": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz",
+ "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==",
+ "license": "MIT",
+ "dependencies": {
+ "moo": "^0.5.1"
+ }
+ },
+ "node_modules/@messageformat/runtime": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz",
+ "integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==",
+ "license": "MIT",
+ "dependencies": {
+ "make-plural": "^7.0.0"
+ }
+ },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
@@ -3227,13 +3321,13 @@
"optional": true
},
"node_modules/@playwright/test": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
- "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright": "1.58.2"
+ "playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -4263,6 +4357,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -4460,6 +4560,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001764",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
@@ -4736,6 +4845,32 @@
"node": ">= 0.10"
}
},
+ "node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "license": "MIT",
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5124,6 +5259,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -5788,6 +5932,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -5835,6 +5995,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "license": "MIT"
+ },
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
@@ -6003,9 +6169,30 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/js-yaml": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
+ "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/jsdom": {
"version": "27.4.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
@@ -6113,6 +6300,12 @@
],
"license": "MIT"
},
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "license": "MIT"
+ },
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
@@ -6311,6 +6504,12 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/make-plural": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.5.0.tgz",
+ "integrity": "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA==",
+ "license": "Unicode-DFS-2016"
+ },
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
@@ -6572,6 +6771,12 @@
"node": ">= 18"
}
},
+ "node_modules/moo": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
+ "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -7080,6 +7285,42 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse-json/node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
@@ -7212,6 +7453,15 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -7223,7 +7473,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -7263,13 +7512,13 @@
}
},
"node_modules/playwright": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
- "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.58.2"
+ "playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -7282,9 +7531,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
- "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -7581,6 +7830,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -7724,6 +7982,12 @@
"tslib": "^2.1.0"
}
},
+ "node_modules/safe-identifier": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz",
+ "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==",
+ "license": "ISC"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -8413,7 +8677,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
diff --git a/wordkeep-client/package.json b/wordkeep-client/package.json
index a9980a7..ef056fa 100644
--- a/wordkeep-client/package.json
+++ b/wordkeep-client/package.json
@@ -7,7 +7,8 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test --watch=false",
- "test:e2e": "npx playwright test"
+ "test:e2e": "npx playwright test",
+ "check:i18n": "node scripts/check-i18n.mjs"
},
"prettier": {
"printWidth": 100,
@@ -30,6 +31,8 @@
"@angular/forms": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0",
+ "@jsverse/transloco": "^8.3.0",
+ "@jsverse/transloco-messageformat": "^8.3.0",
"@types/turndown": "^5.0.6",
"dictionary-de": "^3.0.0",
"dictionary-en": "^4.0.0",
@@ -50,7 +53,7 @@
"@angular/build": "^21.1.0",
"@angular/cli": "^21.1.0",
"@angular/compiler-cli": "^21.1.0",
- "@playwright/test": "^1.58.2",
+ "@playwright/test": "^1.60.0",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
"vitest": "^4.0.8"
diff --git a/wordkeep-client/public/assets/demo/pages/p1.jpg b/wordkeep-client/public/assets/demo/pages/p1.jpg
new file mode 100644
index 0000000..8acafee
Binary files /dev/null and b/wordkeep-client/public/assets/demo/pages/p1.jpg differ
diff --git a/wordkeep-client/public/assets/demo/pages/p2.jpg b/wordkeep-client/public/assets/demo/pages/p2.jpg
new file mode 100644
index 0000000..a750fb8
Binary files /dev/null and b/wordkeep-client/public/assets/demo/pages/p2.jpg differ
diff --git a/wordkeep-client/public/assets/demo/pages/p3.jpg b/wordkeep-client/public/assets/demo/pages/p3.jpg
new file mode 100644
index 0000000..3aec124
Binary files /dev/null and b/wordkeep-client/public/assets/demo/pages/p3.jpg differ
diff --git a/wordkeep-client/public/assets/i18n/auth/en.json b/wordkeep-client/public/assets/i18n/auth/en.json
new file mode 100644
index 0000000..b4f803b
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/auth/en.json
@@ -0,0 +1,102 @@
+{
+ "common": {
+ "brand": "WordKeep",
+ "brandReturn": "WordKeep — return to landing page",
+ "email": "Email",
+ "emailPlaceholder": "Enter your email",
+ "password": "Password",
+ "backToLogin": "Back to login",
+ "signIn": "Sign in",
+ "rememberPassword": "Remember your password?",
+ "genericError": "An error occurred. Please try again.",
+ "fillAllFields": "Please fill in all fields",
+ "passwordsNoMatch": "Passwords do not match",
+ "passwordTooShort": "Password must be at least 8 characters"
+ },
+ "login": {
+ "welcomeBack": "Welcome back",
+ "subtitle": "Sign in to your WordKeep account",
+ "twoFactorTitle": "Two-factor authentication",
+ "twoFactorSubtitle": "Enter the 6-digit code from your authenticator app",
+ "verificationSent": "Verification email sent. Please check your inbox.",
+ "resend": "Resend verification email",
+ "sending": "Sending...",
+ "passwordPlaceholder": "Enter your password",
+ "forgotPassword": "Forgot password?",
+ "signingIn": "Signing in...",
+ "noAccount": "Don't have an account?",
+ "createOne": "Create one",
+ "authCode": "Authenticator code",
+ "codePlaceholder": "000000",
+ "recoveryHint": "Or enter a recovery code if you've lost your device.",
+ "verify": "Verify",
+ "verifying": "Verifying...",
+ "invalidCredentials": "Invalid credentials",
+ "enterCode": "Please enter your code.",
+ "invalidCode": "Invalid code. Try again.",
+ "resendFailed": "Failed to resend verification email. Please try again."
+ },
+ "register": {
+ "checkEmailTitle": "Check your email",
+ "checkEmailBody": "We've sent a verification link to {{email}} . Click the link to activate your account.",
+ "linkExpires": "Link expires in 5 days.",
+ "title": "Create account",
+ "subtitle": "Get started with WordKeep",
+ "name": "Name",
+ "namePlaceholder": "Enter your name",
+ "passwordCreatePlaceholder": "Create a password",
+ "passwordHint": "Must be at least 8 characters",
+ "confirmPassword": "Confirm Password",
+ "confirmPlaceholder": "Confirm your password",
+ "creatingAccount": "Creating account...",
+ "createAccount": "Create account",
+ "haveAccount": "Already have an account?",
+ "validationFailed": "Validation failed. Please check your input."
+ },
+ "forgot": {
+ "title": "Forgot password?",
+ "subtitle": "Enter your email and we'll send you a reset link",
+ "checkEmailTitle": "Check your email",
+ "checkEmailBody": "If an account exists for {{email}} , you'll receive a password reset link.",
+ "spamNote": "Didn't receive the email? Check your spam folder.",
+ "sendReset": "Send reset link",
+ "sending": "Sending...",
+ "enterEmail": "Please enter your email"
+ },
+ "reset": {
+ "title": "Reset password",
+ "subtitle": "Choose a new password for your account",
+ "invalidTitle": "Invalid reset link",
+ "invalidBody": "This password reset link is invalid or has expired.",
+ "requestNew": "Request new link",
+ "successTitle": "Password reset!",
+ "successBody": "Your password has been changed. Redirecting...",
+ "newPassword": "New password",
+ "newPlaceholder": "Enter new password",
+ "passwordHint": "Minimum 8 characters",
+ "confirmPassword": "Confirm password",
+ "confirmPlaceholder": "Confirm new password",
+ "resetting": "Resetting...",
+ "resetButton": "Reset password",
+ "resetFailed": "Reset failed. The link may be invalid or expired."
+ },
+ "verifyEmail": {
+ "verifyingTitle": "Verifying your email...",
+ "verifyingBody": "Please wait while we verify your email address.",
+ "successTitle": "Email verified!",
+ "successBody": "Your account has been activated. Redirecting to dashboard...",
+ "failedTitle": "Verification failed",
+ "noToken": "No verification token provided.",
+ "verifyFailed": "Verification failed. The link may be invalid or expired."
+ },
+ "verifyChange": {
+ "verifyingTitle": "Verifying your new email...",
+ "verifyingBody": "Please wait while we confirm your email change.",
+ "successTitle": "Email updated!",
+ "successBody": "Your email address has been changed successfully. Redirecting...",
+ "failedTitle": "Verification failed",
+ "backToProfile": "Back to Profile",
+ "noToken": "No verification token provided.",
+ "verifyFailed": "Verification failed. The link may be invalid or expired."
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/auth/ro.json b/wordkeep-client/public/assets/i18n/auth/ro.json
new file mode 100644
index 0000000..a146fb7
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/auth/ro.json
@@ -0,0 +1,102 @@
+{
+ "common": {
+ "brand": "WordKeep",
+ "brandReturn": "WordKeep — înapoi la pagina principală",
+ "email": "E-mail",
+ "emailPlaceholder": "Introdu adresa de e-mail",
+ "password": "Parolă",
+ "backToLogin": "Înapoi la autentificare",
+ "signIn": "Autentifică-te",
+ "rememberPassword": "Ți-ai amintit parola?",
+ "genericError": "A apărut o eroare. Te rugăm să încerci din nou.",
+ "fillAllFields": "Te rugăm să completezi toate câmpurile",
+ "passwordsNoMatch": "Parolele nu se potrivesc",
+ "passwordTooShort": "Parola trebuie să aibă cel puțin 8 caractere"
+ },
+ "login": {
+ "welcomeBack": "Bine ai revenit",
+ "subtitle": "Autentifică-te în contul tău WordKeep",
+ "twoFactorTitle": "Autentificare în doi pași",
+ "twoFactorSubtitle": "Introdu codul din 6 cifre din aplicația ta de autentificare",
+ "verificationSent": "E-mailul de confirmare a fost trimis. Verifică-ți căsuța de e-mail.",
+ "resend": "Retrimite e-mailul de confirmare",
+ "sending": "Se trimite...",
+ "passwordPlaceholder": "Introdu parola",
+ "forgotPassword": "Ai uitat parola?",
+ "signingIn": "Se autentifică...",
+ "noAccount": "Nu ai un cont?",
+ "createOne": "Creează unul",
+ "authCode": "Cod de autentificare",
+ "codePlaceholder": "000000",
+ "recoveryHint": "Sau introdu un cod de recuperare dacă ți-ai pierdut dispozitivul.",
+ "verify": "Verifică",
+ "verifying": "Se verifică...",
+ "invalidCredentials": "Date de autentificare invalide",
+ "enterCode": "Te rugăm să introduci codul.",
+ "invalidCode": "Cod invalid. Încearcă din nou.",
+ "resendFailed": "Retrimiterea e-mailului de confirmare a eșuat. Te rugăm să încerci din nou."
+ },
+ "register": {
+ "checkEmailTitle": "Verifică-ți e-mailul",
+ "checkEmailBody": "Am trimis un link de confirmare către {{email}} . Apasă pe link pentru a-ți activa contul.",
+ "linkExpires": "Linkul expiră în 5 zile.",
+ "title": "Creează cont",
+ "subtitle": "Începe cu WordKeep",
+ "name": "Nume",
+ "namePlaceholder": "Introdu numele tău",
+ "passwordCreatePlaceholder": "Creează o parolă",
+ "passwordHint": "Trebuie să aibă cel puțin 8 caractere",
+ "confirmPassword": "Confirmă parola",
+ "confirmPlaceholder": "Confirmă parola",
+ "creatingAccount": "Se creează contul...",
+ "createAccount": "Creează cont",
+ "haveAccount": "Ai deja un cont?",
+ "validationFailed": "Validarea a eșuat. Verifică datele introduse."
+ },
+ "forgot": {
+ "title": "Ai uitat parola?",
+ "subtitle": "Introdu adresa de e-mail și îți vom trimite un link de resetare",
+ "checkEmailTitle": "Verifică-ți e-mailul",
+ "checkEmailBody": "Dacă există un cont pentru {{email}} , vei primi un link de resetare a parolei.",
+ "spamNote": "Nu ai primit e-mailul? Verifică folderul de spam.",
+ "sendReset": "Trimite linkul de resetare",
+ "sending": "Se trimite...",
+ "enterEmail": "Te rugăm să introduci adresa de e-mail"
+ },
+ "reset": {
+ "title": "Resetează parola",
+ "subtitle": "Alege o parolă nouă pentru contul tău",
+ "invalidTitle": "Link de resetare invalid",
+ "invalidBody": "Acest link de resetare a parolei este invalid sau a expirat.",
+ "requestNew": "Solicită un link nou",
+ "successTitle": "Parolă resetată!",
+ "successBody": "Parola ta a fost schimbată. Se redirecționează...",
+ "newPassword": "Parolă nouă",
+ "newPlaceholder": "Introdu parola nouă",
+ "passwordHint": "Minimum 8 caractere",
+ "confirmPassword": "Confirmă parola",
+ "confirmPlaceholder": "Confirmă parola nouă",
+ "resetting": "Se resetează...",
+ "resetButton": "Resetează parola",
+ "resetFailed": "Resetarea a eșuat. Linkul ar putea fi invalid sau expirat."
+ },
+ "verifyEmail": {
+ "verifyingTitle": "Se verifică e-mailul tău...",
+ "verifyingBody": "Te rugăm să aștepți cât îți verificăm adresa de e-mail.",
+ "successTitle": "E-mail confirmat!",
+ "successBody": "Contul tău a fost activat. Se redirecționează către panou...",
+ "failedTitle": "Verificarea a eșuat",
+ "noToken": "Nu a fost furnizat niciun token de verificare.",
+ "verifyFailed": "Verificarea a eșuat. Linkul ar putea fi invalid sau expirat."
+ },
+ "verifyChange": {
+ "verifyingTitle": "Se verifică noua ta adresă de e-mail...",
+ "verifyingBody": "Te rugăm să aștepți cât confirmăm schimbarea adresei de e-mail.",
+ "successTitle": "E-mail actualizat!",
+ "successBody": "Adresa ta de e-mail a fost schimbată cu succes. Se redirecționează...",
+ "failedTitle": "Verificarea a eșuat",
+ "backToProfile": "Înapoi la profil",
+ "noToken": "Nu a fost furnizat niciun token de verificare.",
+ "verifyFailed": "Verificarea a eșuat. Linkul ar putea fi invalid sau expirat."
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/bookView/en.json b/wordkeep-client/public/assets/i18n/bookView/en.json
new file mode 100644
index 0000000..be08abe
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/bookView/en.json
@@ -0,0 +1,63 @@
+{
+ "list": {
+ "title": "Document View",
+ "subtitle": "Read your documents as assembled text or export as Markdown.",
+ "loadError": "Failed to load documents",
+ "exportError": "Failed to export document",
+ "yourDocuments": "Your Documents",
+ "documentCount": "{count, plural, one {# document} other {# documents}}",
+ "loading": "Loading documents...",
+ "emptyTitle": "No documents ready",
+ "emptySubtitle": "Only ready documents appear here",
+ "renamed": "renamed",
+ "originalName": "Original: {{name}}",
+ "pageCount": "{count, plural, one {# page} other {# pages}}",
+ "extracted": "{{completed}}/{{total}} extracted ({{percent}}%)",
+ "linked": "{{completed}}/{{total}} linked ({{percent}}%)",
+ "detecting": "Detecting...",
+ "noLanguageSet": "No language set",
+ "view": "View",
+ "exporting": "Exporting...",
+ "export": "Export",
+ "exportMd": ".md (Markdown)",
+ "previous": "Previous",
+ "next": "Next",
+ "pageOf": "Page {{current}} of {{total}}",
+ "workIncompleteTitle": "Work incomplete",
+ "viewAnyway": "View anyway",
+ "exportAnyway": "Export anyway",
+ "issueOcr": "text extraction is not complete for all pages",
+ "issueLinks": "page linking is not complete",
+ "issueTrans": "translation is incomplete — untranslated pages will show as placeholders",
+ "incompleteMessage": "This document has issues: {{issues}}. Missing text will show as placeholders and missing links will default to new row.",
+ "issuesJoiner": " and "
+ },
+ "reader": {
+ "loading": "Loading document...",
+ "documentView": "Back to Document View",
+ "noLanguageSet": "No language set",
+ "exporting": "Exporting...",
+ "exportMd": "Export .md",
+ "switchToSinglePage": "Switch to single page",
+ "switchToTwoPages": "Switch to two pages",
+ "onePage": "1 page",
+ "twoPages": "2 pages",
+ "prev": "Prev",
+ "next": "Next",
+ "page": "Page",
+ "pageOf": "of {{total}}",
+ "decreaseFontSize": "Decrease font size",
+ "increaseFontSize": "Increase font size",
+ "loadingContent": "Loading content...",
+ "pagePanelLabel": "Page {{current}} of {{total}}",
+ "endOfDocument": "End of document",
+ "backToDocumentView": "Back to Document View",
+ "loadError": "Failed to load document",
+ "loadPagesError": "Failed to load page content",
+ "noTranslationAvailable": "*No translation available.*",
+ "incompleteTranslationTitle": "Incomplete translation",
+ "incompleteTranslationMessage": "The translation to {{language}} is not complete ({{completed}}/{{total}} pages). Untranslated pages will show as placeholders.",
+ "viewAnyway": "View anyway",
+ "cancel": "Cancel"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/bookView/ro.json b/wordkeep-client/public/assets/i18n/bookView/ro.json
new file mode 100644
index 0000000..d962f7e
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/bookView/ro.json
@@ -0,0 +1,63 @@
+{
+ "list": {
+ "title": "Vizualizare document",
+ "subtitle": "Citește documentele ca text asamblat sau exportă-le ca Markdown.",
+ "loadError": "Încărcarea documentelor a eșuat",
+ "exportError": "Exportul documentului a eșuat",
+ "yourDocuments": "Documentele tale",
+ "documentCount": "{count, plural, one {# document} few {# documente} other {# de documente}}",
+ "loading": "Se încarcă documentele...",
+ "emptyTitle": "Niciun document pregătit",
+ "emptySubtitle": "Aici apar doar documentele pregătite",
+ "renamed": "redenumit",
+ "originalName": "Original: {{name}}",
+ "pageCount": "{count, plural, one {# pagină} few {# pagini} other {# de pagini}}",
+ "extracted": "{{completed}}/{{total}} extrase ({{percent}}%)",
+ "linked": "{{completed}}/{{total}} legate ({{percent}}%)",
+ "detecting": "Se detectează...",
+ "noLanguageSet": "Nicio limbă setată",
+ "view": "Vizualizează",
+ "exporting": "Se exportă...",
+ "export": "Exportă",
+ "exportMd": ".md (Markdown)",
+ "previous": "Anterioara",
+ "next": "Următoarea",
+ "pageOf": "Pagina {{current}} din {{total}}",
+ "workIncompleteTitle": "Procesare incompletă",
+ "viewAnyway": "Vizualizează oricum",
+ "exportAnyway": "Exportă oricum",
+ "issueOcr": "extragerea textului nu este completă pentru toate paginile",
+ "issueLinks": "legarea paginilor nu este completă",
+ "issueTrans": "traducerea este incompletă — paginile netraduse vor apărea ca substituenți",
+ "incompleteMessage": "Acest document are probleme: {{issues}}. Textul lipsă va apărea ca substituenți, iar legăturile lipsă vor fi tratate implicit ca rând nou.",
+ "issuesJoiner": " și "
+ },
+ "reader": {
+ "loading": "Se încarcă documentul...",
+ "documentView": "Înapoi la Vizualizare document",
+ "noLanguageSet": "Nicio limbă setată",
+ "exporting": "Se exportă...",
+ "exportMd": "Exportă .md",
+ "switchToSinglePage": "Comută la o singură pagină",
+ "switchToTwoPages": "Comută la două pagini",
+ "onePage": "1 pagină",
+ "twoPages": "2 pagini",
+ "prev": "Anterioara",
+ "next": "Următoarea",
+ "page": "Pagina",
+ "pageOf": "din {{total}}",
+ "decreaseFontSize": "Micșorează dimensiunea textului",
+ "increaseFontSize": "Mărește dimensiunea textului",
+ "loadingContent": "Se încarcă conținutul...",
+ "pagePanelLabel": "Pagina {{current}} din {{total}}",
+ "endOfDocument": "Sfârșitul documentului",
+ "backToDocumentView": "Înapoi la vizualizarea documentelor",
+ "loadError": "Încărcarea documentului a eșuat",
+ "loadPagesError": "Încărcarea conținutului paginii a eșuat",
+ "noTranslationAvailable": "*Nicio traducere disponibilă.*",
+ "incompleteTranslationTitle": "Traducere incompletă",
+ "incompleteTranslationMessage": "Traducerea în {{language}} nu este completă ({{completed}}/{{total}} pagini). Paginile netraduse vor apărea ca substituenți.",
+ "viewAnyway": "Vizualizează oricum",
+ "cancel": "Anulează"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/charter/en.json b/wordkeep-client/public/assets/i18n/charter/en.json
new file mode 100644
index 0000000..7e73778
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/charter/en.json
@@ -0,0 +1,48 @@
+{
+ "flags": {
+ "en": "English",
+ "ro": "Romanian",
+ "ariaEn": "Switch the charter to English",
+ "ariaRo": "Switch the charter to Romanian"
+ },
+ "subLegend": "Granted by the Order of the Steadfast Knight, in the year of grace MMXXVI.",
+ "cta": {
+ "witness": "Witness the craft",
+ "subscribe": "Subscribe to the charter",
+ "resume": "Resume the office"
+ },
+ "colophon": "Granted at Sibiu · A. D. MMXXVI · A Steadfast Knight imprint",
+ "heading": {
+ "translation": "Founding Charter"
+ },
+ "opening": {
+ "translation": "L L et those present and to come know that we, Master of the Order of the Steadfast Knight , in honor of the keeping of books, have granted, established, and by this present charter confirmed the workshop called WordKeep , for the guarding of words, the joining of leaves, and the turning of tongues, for as long as books shall be."
+ },
+ "call": {
+ "translation": "Moreover, these three offices have been appointed, under one seal:"
+ },
+ "article1": {
+ "title": "Of the Reading of the Page",
+ "body": "That pages already written, by pen or by type, shall be returned in faithful reading; the rubrics, the hands, and the marginalia shall be preserved inviolate, nothing omitted.",
+ "sign": "— by my own hand",
+ "demoLink": "See the reading at work"
+ },
+ "article2": {
+ "title": "Of the Joining of the Leaves",
+ "body": "That facing pages shall be sewn together, sentences divided across leaves reconciled, and notes placed below restored to their own words.",
+ "sign": "— by my own hand",
+ "demoLink": "See the joining at work"
+ },
+ "article3": {
+ "title": "Of the Turning of the Tongue",
+ "body": "That the volume shall be turned into another tongue, page with page and note with note, the text itself standing fast. The reader shall pay only for the leaves he turns, and not before.",
+ "sign": "— by my own hand",
+ "demoLink": "See the turning at work"
+ },
+ "closing": {
+ "translation": "Given at Sibiu, on the 8th day of the month of May, in the year of grace 2026 . This charter, under our seals, we ratify in perpetuity."
+ },
+ "signed": {
+ "translation": "— Master of the Order of the Steadfast Knight"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/charter/ro.json b/wordkeep-client/public/assets/i18n/charter/ro.json
new file mode 100644
index 0000000..6e81fd8
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/charter/ro.json
@@ -0,0 +1,48 @@
+{
+ "flags": {
+ "en": "Engleză",
+ "ro": "Română",
+ "ariaEn": "Comută carta în engleză",
+ "ariaRo": "Comută carta în română"
+ },
+ "subLegend": "Hărăzită de Ordinul Cavalerului Statornic, în anul de grație MMXXVI.",
+ "cta": {
+ "witness": "Vezi meșteșugul",
+ "subscribe": "Subscrie la cartă",
+ "resume": "Reia slujba"
+ },
+ "colophon": "Hărăzită la Sibiu · A. D. MMXXVI · O emblemă a Cavalerului Statornic",
+ "heading": {
+ "translation": "Carta de întemeiere"
+ },
+ "opening": {
+ "translation": "S S ă știe cei de față și cei ce vor veni că noi, Magistru al Ordinului Cavalerului Statornic , întru cinstea păstrării cărților, am hărăzit, am rânduit și prin această carte de față am întărit atelierul numit WordKeep , spre paza cuvintelor, împreunarea filelor și întoarcerea limbilor, atâta vreme cât vor fi cărți."
+ },
+ "call": {
+ "translation": "Mai mult, s-au rânduit aceste trei slujbe, sub o singură pecete:"
+ },
+ "article1": {
+ "title": "Despre citirea paginii",
+ "body": "Ca filele deja scrise, cu pana ori cu tiparul, să fie redate într-o citire credincioasă; rubricile, scrisul de mână și însemnările de pe margine să fie păstrate neatinse, nimic să nu fie lăsat afară.",
+ "sign": "— cu mâna mea",
+ "demoLink": "Vezi citirea la lucru"
+ },
+ "article2": {
+ "title": "Despre împreunarea filelor",
+ "body": "Ca paginile alăturate să fie cusute laolaltă, propozițiile despărțite peste file să fie împăcate, iar notele așezate dedesubt să fie întoarse la cuvintele lor.",
+ "sign": "— cu mâna mea",
+ "demoLink": "Vezi împreunarea la lucru"
+ },
+ "article3": {
+ "title": "Despre întoarcerea limbii",
+ "body": "Ca volumul să fie întors într-o altă limbă, pagină cu pagină și notă cu notă, însuși textul rămânând neclintit. Cititorul va plăti numai pentru filele pe care le întoarce, și nu mai înainte.",
+ "sign": "— cu mâna mea",
+ "demoLink": "Vezi întoarcerea la lucru"
+ },
+ "closing": {
+ "translation": "Dat la Sibiu, în ziua a opta a lunii mai, în anul de grație 2026 . Această carte, sub pecețile noastre, o întărim în veci."
+ },
+ "signed": {
+ "translation": "— Magistru al Ordinului Cavalerului Statornic"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/demo/en.json b/wordkeep-client/public/assets/i18n/demo/en.json
new file mode 100644
index 0000000..efb1c8e
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/demo/en.json
@@ -0,0 +1,35 @@
+{
+ "chrome": {
+ "brand": "WordKeep",
+ "exit": "Return to the charter",
+ "exitToLanding": "Back to the charter",
+ "progressLabel": "Demo progress",
+ "localeAria": "Switch the demo language",
+ "back": "Back",
+ "next": "Proceed",
+ "finish": "Subscribe to the charter"
+ },
+ "progress": {
+ "extract": "Reading",
+ "link": "Joining",
+ "book": "The volume",
+ "translate": "Turning",
+ "bookTranslated": "Turned"
+ },
+ "personalise": {
+ "aria": "Personalise the demo",
+ "legend": "To taste",
+ "palette": "Colour palette"
+ },
+ "stages": {
+ "extract": { "standalone": "Read and amend freely — every page stands alone; nothing you change here is carried onward." },
+ "link": { "standalone": "Join the pages however you wish — these choices are yours to try and do not bind the chapters that follow." },
+ "book": { "standalone": "The leaves set in clean type — the finished reading, gathered as one." },
+ "translate": { "standalone": "Render and revise as you like — this turning stands on its own, untouched by the earlier steps." },
+ "book-translated": { "standalone": "The volume, turned — the finished translation, leaf for leaf." }
+ },
+ "signup": {
+ "note": "A rehearsed specimen — nothing you do here is kept.",
+ "cta": "Subscribe to keep your own"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/demo/ro.json b/wordkeep-client/public/assets/i18n/demo/ro.json
new file mode 100644
index 0000000..65fec3c
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/demo/ro.json
@@ -0,0 +1,35 @@
+{
+ "chrome": {
+ "brand": "WordKeep",
+ "exit": "Înapoi la cartă",
+ "exitToLanding": "Înapoi la cartă",
+ "progressLabel": "Progresul demonstrației",
+ "localeAria": "Schimbă limba demonstrației",
+ "back": "Înapoi",
+ "next": "Continuă",
+ "finish": "Abonează-te la cartă"
+ },
+ "progress": {
+ "extract": "Citire",
+ "link": "Unire",
+ "book": "Volumul",
+ "translate": "Traducere",
+ "bookTranslated": "Tradus"
+ },
+ "personalise": {
+ "aria": "Personalizează demonstrația",
+ "legend": "După gust",
+ "palette": "Paletă de culori"
+ },
+ "stages": {
+ "extract": { "standalone": "Citește și îndreaptă în voie — fiecare pagină stă de sine; nimic din ce schimbi aici nu este dus mai departe." },
+ "link": { "standalone": "Împreunează filele cum dorești — aceste alegeri sunt doar pentru încercare și nu leagă capitolele ce urmează." },
+ "book": { "standalone": "Filele așezate în literă curată — citirea împlinită, adunată laolaltă." },
+ "translate": { "standalone": "Întoarce și îndreaptă cum dorești — această întoarcere stă de sine, neatinsă de pașii dinainte." },
+ "book-translated": { "standalone": "Volumul, întors — traducerea împlinită, filă cu filă." }
+ },
+ "signup": {
+ "note": "Un specimen demonstrativ — nimic din ce faci aici nu este păstrat.",
+ "cta": "Abonează-te ca să-ți păstrezi propriile cărți"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/dictionary/en.json b/wordkeep-client/public/assets/i18n/dictionary/en.json
new file mode 100644
index 0000000..c5fbf7a
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/dictionary/en.json
@@ -0,0 +1,15 @@
+{
+ "header": {
+ "title": "My Dictionary",
+ "subtitle": "Custom words recognised by the spell checker."
+ },
+ "form": {
+ "placeholder": "Add a word…",
+ "add": "Add"
+ },
+ "emptyState": "No custom words yet. Add words above to skip spell-check errors for them.",
+ "remove": "Remove",
+ "errors": {
+ "addFailed": "Failed to add word. Please try again."
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/dictionary/ro.json b/wordkeep-client/public/assets/i18n/dictionary/ro.json
new file mode 100644
index 0000000..bf5ea94
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/dictionary/ro.json
@@ -0,0 +1,15 @@
+{
+ "header": {
+ "title": "Dicționarul meu",
+ "subtitle": "Cuvinte personalizate recunoscute de corectorul ortografic."
+ },
+ "form": {
+ "placeholder": "Adaugă un cuvânt…",
+ "add": "Adaugă"
+ },
+ "emptyState": "Niciun cuvânt personalizat încă. Adaugă cuvinte mai sus pentru a evita erorile de corectare ortografică pentru ele.",
+ "remove": "Elimină",
+ "errors": {
+ "addFailed": "Adăugarea cuvântului a eșuat. Încearcă din nou."
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/documents/en.json b/wordkeep-client/public/assets/i18n/documents/en.json
new file mode 100644
index 0000000..4f348c3
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/documents/en.json
@@ -0,0 +1,134 @@
+{
+ "list": {
+ "title": "Documents",
+ "subtitle": "Upload and manage your PDF documents",
+ "upload": {
+ "uploading": "Uploading document...",
+ "dragDrop": "Drag and drop a PDF file here, or",
+ "browse": "Browse Files",
+ "maxSize": "Maximum file size: 100MB"
+ },
+ "section": {
+ "heading": "Your Documents",
+ "count": "{{count}} document",
+ "countPlural": "{{count}} documents"
+ },
+ "loading": "Loading documents...",
+ "empty": {
+ "title": "No documents yet",
+ "subtitle": "Upload your first PDF to get started"
+ },
+ "status": {
+ "uploaded": "Uploaded",
+ "processing": "Processing",
+ "ready": "Ready",
+ "failed": "Failed"
+ },
+ "card": {
+ "renamed": "renamed",
+ "originalTitle": "Original: {{name}}",
+ "page": "{{count}} page",
+ "pagePlural": "{{count}} pages",
+ "retryTitle": "Retry processing",
+ "renameTitle": "Rename document",
+ "deleteTitle": "Delete document"
+ },
+ "pagination": {
+ "previous": "Previous",
+ "next": "Next",
+ "pageInfo": "Page {{current}} of {{last}}"
+ },
+ "errors": {
+ "load": "Failed to load documents",
+ "onlyPdf": "Only PDF files are allowed",
+ "upload": "Failed to upload document",
+ "retry": "Failed to retry document",
+ "delete": "Failed to delete document",
+ "rename": "Failed to rename document"
+ }
+ },
+ "detail": {
+ "loading": "Loading document...",
+ "notDetected": "Not detected",
+ "genres": {
+ "Fiction": "Fiction",
+ "Non-Fiction": "Non-Fiction",
+ "Technical": "Technical",
+ "Academic": "Academic",
+ "Legal": "Legal",
+ "Business": "Business",
+ "Personal": "Personal",
+ "Other": "Other"
+ },
+ "notFound": {
+ "title": "Document not found",
+ "back": "Back to Documents"
+ },
+ "backToDocuments": "Back to Documents",
+ "title": "Document Details",
+ "cover": {
+ "alt": "Document cover"
+ },
+ "actions": {
+ "openExtraction": "Open Text Extraction",
+ "openPageLinking": "Open Page Linking",
+ "openTranslation": "Open Translation",
+ "viewAsDocument": "View as Document",
+ "exporting": "Exporting...",
+ "exportMd": "Export .md"
+ },
+ "form": {
+ "nameLabel": "Name",
+ "namePlaceholder": "Document name",
+ "original": "Original: {{name}}",
+ "authorLabel": "Author",
+ "authorPlaceholder": "Author name",
+ "genreLabel": "Genre",
+ "genrePlaceholder": "Select genre...",
+ "languageLabel": "Language",
+ "languageTooltip": "Language is used for spell checking during text extraction",
+ "languageNotSet": "Not set",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "Add a description...",
+ "cancel": "Cancel",
+ "saving": "Saving...",
+ "save": "Save Changes"
+ },
+ "view": {
+ "renamed": "renamed",
+ "originalTitle": "Original: {{name}}",
+ "author": "Author",
+ "genre": "Genre",
+ "pages": "Pages",
+ "size": "Size",
+ "format": "Format",
+ "language": "Language",
+ "languageTooltip": "Language is used for spell checking during text extraction",
+ "detectionFailed": "Detection failed",
+ "retry": "Retry",
+ "detecting": "Detecting...",
+ "added": "Added",
+ "description": "Description",
+ "edit": "Edit",
+ "delete": "Delete"
+ },
+ "errors": {
+ "load": "Failed to load document",
+ "save": "Failed to save changes",
+ "delete": "Failed to delete document",
+ "detect": "Failed to detect language",
+ "export": "Failed to export document"
+ },
+ "incomplete": {
+ "title": "Work incomplete",
+ "missingText": "{{count}} page without extracted text",
+ "missingTextPlural": "{{count}} pages without extracted text",
+ "missingLinks": "{{count}} unlinked page pair",
+ "missingLinksPlural": "{{count}} unlinked page pairs",
+ "joiner": " and ",
+ "message": "This document has: {{issues}}. Missing text will show as placeholders and missing links will default to new row.",
+ "viewAnyway": "View anyway",
+ "exportAnyway": "Export anyway"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/documents/ro.json b/wordkeep-client/public/assets/i18n/documents/ro.json
new file mode 100644
index 0000000..32f6c6f
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/documents/ro.json
@@ -0,0 +1,134 @@
+{
+ "list": {
+ "title": "Documente",
+ "subtitle": "Încarcă și gestionează documentele tale PDF",
+ "upload": {
+ "uploading": "Se încarcă documentul...",
+ "dragDrop": "Trage și plasează un fișier PDF aici sau",
+ "browse": "Răsfoiește fișiere",
+ "maxSize": "Dimensiune maximă a fișierului: 100MB"
+ },
+ "section": {
+ "heading": "Documentele tale",
+ "count": "{{count}} document",
+ "countPlural": "{{count}} documente"
+ },
+ "loading": "Se încarcă documentele...",
+ "empty": {
+ "title": "Niciun document încă",
+ "subtitle": "Încarcă primul tău PDF pentru a începe"
+ },
+ "status": {
+ "uploaded": "Încărcat",
+ "processing": "Se procesează",
+ "ready": "Gata",
+ "failed": "Eșuat"
+ },
+ "card": {
+ "renamed": "redenumit",
+ "originalTitle": "Original: {{name}}",
+ "page": "{{count}} pagină",
+ "pagePlural": "{{count}} pagini",
+ "retryTitle": "Reîncearcă procesarea",
+ "renameTitle": "Redenumește documentul",
+ "deleteTitle": "Șterge documentul"
+ },
+ "pagination": {
+ "previous": "Anterioara",
+ "next": "Următoarea",
+ "pageInfo": "Pagina {{current}} din {{last}}"
+ },
+ "errors": {
+ "load": "Încărcarea documentelor a eșuat",
+ "onlyPdf": "Sunt permise doar fișiere PDF",
+ "upload": "Încărcarea documentului a eșuat",
+ "retry": "Reîncercarea documentului a eșuat",
+ "delete": "Ștergerea documentului a eșuat",
+ "rename": "Redenumirea documentului a eșuat"
+ }
+ },
+ "detail": {
+ "loading": "Se încarcă documentul...",
+ "notDetected": "Nedetectată",
+ "genres": {
+ "Fiction": "Ficțiune",
+ "Non-Fiction": "Non-ficțiune",
+ "Technical": "Tehnic",
+ "Academic": "Academic",
+ "Legal": "Juridic",
+ "Business": "Afaceri",
+ "Personal": "Personal",
+ "Other": "Altele"
+ },
+ "notFound": {
+ "title": "Document negăsit",
+ "back": "Înapoi la Documente"
+ },
+ "backToDocuments": "Înapoi la Documente",
+ "title": "Detalii document",
+ "cover": {
+ "alt": "Coperta documentului"
+ },
+ "actions": {
+ "openExtraction": "Deschide extragerea textului",
+ "openPageLinking": "Deschide legarea paginilor",
+ "openTranslation": "Deschide traducerea",
+ "viewAsDocument": "Vezi ca document",
+ "exporting": "Se exportă...",
+ "exportMd": "Exportă .md"
+ },
+ "form": {
+ "nameLabel": "Nume",
+ "namePlaceholder": "Numele documentului",
+ "original": "Original: {{name}}",
+ "authorLabel": "Autor",
+ "authorPlaceholder": "Numele autorului",
+ "genreLabel": "Gen",
+ "genrePlaceholder": "Selectează genul...",
+ "languageLabel": "Limbă",
+ "languageTooltip": "Limba este folosită pentru verificarea ortografică în timpul extragerii textului",
+ "languageNotSet": "Nesetată",
+ "descriptionLabel": "Descriere",
+ "descriptionPlaceholder": "Adaugă o descriere...",
+ "cancel": "Anulează",
+ "saving": "Se salvează...",
+ "save": "Salvează modificările"
+ },
+ "view": {
+ "renamed": "redenumit",
+ "originalTitle": "Original: {{name}}",
+ "author": "Autor",
+ "genre": "Gen",
+ "pages": "Pagini",
+ "size": "Dimensiune",
+ "format": "Format",
+ "language": "Limbă",
+ "languageTooltip": "Limba este folosită pentru verificarea ortografică în timpul extragerii textului",
+ "detectionFailed": "Detectarea a eșuat",
+ "retry": "Reîncearcă",
+ "detecting": "Se detectează...",
+ "added": "Adăugat",
+ "description": "Descriere",
+ "edit": "Editează",
+ "delete": "Șterge"
+ },
+ "errors": {
+ "load": "Încărcarea documentului a eșuat",
+ "save": "Salvarea modificărilor a eșuat",
+ "delete": "Ștergerea documentului a eșuat",
+ "detect": "Detectarea limbii a eșuat",
+ "export": "Exportarea documentului a eșuat"
+ },
+ "incomplete": {
+ "title": "Lucrare incompletă",
+ "missingText": "{{count}} pagină fără text extras",
+ "missingTextPlural": "{{count}} pagini fără text extras",
+ "missingLinks": "{{count}} pereche de pagini nelegată",
+ "missingLinksPlural": "{{count}} perechi de pagini nelegate",
+ "joiner": " și ",
+ "message": "Acest document are: {{issues}}. Textul lipsă va apărea ca substituent, iar legăturile lipsă vor folosi implicit un rând nou.",
+ "viewAnyway": "Vezi oricum",
+ "exportAnyway": "Exportă oricum"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/en.json b/wordkeep-client/public/assets/i18n/en.json
new file mode 100644
index 0000000..d491a24
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/en.json
@@ -0,0 +1,60 @@
+{
+ "language": {
+ "label": "Language",
+ "en": "EN",
+ "ro": "RO",
+ "enName": "English",
+ "roName": "Romanian",
+ "switch": "Switch language"
+ },
+ "userMenu": {
+ "profileInfo": "Profile Info",
+ "accountSecurity": "Account Security",
+ "personalisation": "Personalisation",
+ "dictionary": "My Dictionary",
+ "logout": "Logout"
+ },
+ "common": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "close": "Close",
+ "delete": "Delete",
+ "edit": "Edit",
+ "loading": "Loading…"
+ },
+ "confirmations": {
+ "deleteDocument": {
+ "title": "Delete Document",
+ "message": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
+ "confirm": "Delete"
+ },
+ "overrideOcrSingle": {
+ "title": "Override OCR Processing",
+ "message": "This will re-process this page with OCR.",
+ "warning": "Any manual text edits or formatting changes on this page will be lost.",
+ "confirm": "Run OCR"
+ },
+ "overrideExtractTextSingle": {
+ "title": "Re-extract Text",
+ "message": "This will re-extract text from this page.",
+ "warning": "Any manual text edits or formatting changes on this page will be lost.",
+ "confirm": "Extract Text"
+ },
+ "overrideOcrBulk": {
+ "title": "Override OCR Processing",
+ "message": "This will re-process all pages with OCR.",
+ "warning": "Any manual text edits or formatting changes on all pages will be lost.",
+ "confirm": "Run OCR"
+ }
+ },
+ "languages": {
+ "ar": "Arabic", "bg": "Bulgarian", "zh": "Chinese", "hr": "Croatian", "cs": "Czech",
+ "da": "Danish", "nl": "Dutch", "en": "English", "fi": "Finnish", "fr": "French",
+ "de": "German", "el": "Greek", "he": "Hebrew", "hi": "Hindi", "hu": "Hungarian",
+ "id": "Indonesian", "it": "Italian", "ja": "Japanese", "ko": "Korean", "la": "Latin",
+ "ms": "Malay", "no": "Norwegian", "pl": "Polish", "pt": "Portuguese", "ro": "Romanian",
+ "ru": "Russian", "sr": "Serbian", "sk": "Slovak", "sl": "Slovenian", "es": "Spanish",
+ "sv": "Swedish", "th": "Thai", "tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese",
+ "unknown": "Unknown"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/extraction/en.json b/wordkeep-client/public/assets/i18n/extraction/en.json
new file mode 100644
index 0000000..01e18a3
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/extraction/en.json
@@ -0,0 +1,179 @@
+{
+ "list": {
+ "title": "Text Extraction",
+ "subtitle": "Extract text from your PDF documents using OCR",
+ "yourDocuments": "Your Documents",
+ "docCount": "{count, plural, one {# document} other {# documents}}",
+ "loading": "Loading documents...",
+ "emptyTitle": "No documents yet",
+ "emptySubtitle": "Upload documents in the Documents section first",
+ "renamed": "renamed",
+ "originalTitle": "Original: {name}",
+ "pageCount": "{count, plural, one {# page} other {# pages}}",
+ "ocrProgress": "{completed}/{total} extracted ({percent}%)",
+ "cancelOcr": "Cancel OCR",
+ "overrideTitle": "Re-run OCR on pages that have already been processed",
+ "override": "Override",
+ "runAllTitle": "Run AI OCR on all pages",
+ "runAll": "AI OCR All",
+ "previous": "Previous",
+ "pageOf": "Page {current} of {last}",
+ "next": "Next",
+ "errors": {
+ "loadFailed": "Failed to load documents"
+ }
+ },
+ "reader": {
+ "loading": "Loading document...",
+ "notFoundTitle": "Document not found",
+ "backToExtraction": "Back to Text Extraction",
+ "demoLocked": "Available after you sign up",
+ "renamed": "renamed",
+ "originalTitle": "Original: {name}",
+ "pages": "{count} pages",
+ "detecting": "Detecting...",
+ "languageInfo": "Language is used for spell checking during text extraction",
+ "pageHeader": "Page {current} of {total}",
+ "ignoredBadge": "Ignored",
+ "ignoredBadgeTitle": "This page is ignored. It will not be included in the exported document.",
+ "unignoreTitle": "Un-ignore this page",
+ "ignoreTitle": "Ignore this page (exclude from exported document)",
+ "unignore": "Un-ignore",
+ "ignore": "Ignore",
+ "firstUnprocessed": "First Unprocessed",
+ "firstUnprocessedTitle": "Go to first page with no text",
+ "nextUnprocessed": "Next Unprocessed",
+ "nextUnprocessedTitle": "Go to next page with no text from current",
+ "pageAlt": "Page {page}",
+ "loadingPage": "Loading page...",
+ "imageError": "Failed to load page image",
+ "extractedText": "Extracted Text",
+ "edit": "Edit",
+ "clear": "Clear",
+ "extracting": "Extracting...",
+ "extractText": "Extract Text",
+ "processing": "Processing...",
+ "aiOcr": "AI OCR",
+ "transcribe": "Transcribe",
+ "cancel": "Cancel",
+ "saving": "Saving...",
+ "save": "Save",
+ "ignoredHeading": "This page is ignored",
+ "ignoredHint": "It will not appear in the exported document. Click \"Un-ignore\" to re-enable text extraction.",
+ "editorPlaceholder": "Enter text...",
+ "processingHeading": "Processing page with AI OCR...",
+ "processingHint": "This may take a moment",
+ "extractingHeading": "Extracting text from PDF...",
+ "extractingHint": "This should be quick",
+ "cached": "Cached result",
+ "ocrFailed": "OCR processing failed",
+ "noText": "No text available",
+ "noTextHint": "Use the buttons above or transcribe manually",
+ "transcribeManually": "Transcribe manually",
+ "footnotes": "Footnotes",
+ "footnotesCounter": "of {total}",
+ "deleteFootnote": "Delete",
+ "unlink": "Unlink",
+ "addFootnote": "Add Footnote",
+ "footnotePlaceholder": "Enter footnote text...",
+ "loadingFootnotes": "Loading footnotes...",
+ "footnoteOnPage": "This footnote is on page {page}",
+ "goToPage": "Go to page {page}",
+ "footnotePageBadge": "Page {page}",
+ "footnoteUnlinked": "Unlinked",
+ "noFootnotes": "No footnotes yet",
+ "noFootnotesHint": "Click \"Add Footnote\" to create one",
+ "contentType": {
+ "text": "Text",
+ "image": "Scanned",
+ "hybrid": "Mixed",
+ "unknown": "Unknown"
+ },
+ "contentTypeTooltip": {
+ "text": "Text page. \"Extract Text\" = fast, plain text. \"AI OCR\" = slower, formatted Markdown.",
+ "image": "Scanned page. Use \"AI OCR\" to extract text using AI vision.",
+ "hybrid": "Mixed page (text + images). \"Extract Text\" = fast, plain text. \"AI OCR\" = formatted with image content."
+ },
+ "extractionMethod": {
+ "text_extraction": "Direct",
+ "ocr": "OCR",
+ "manual": "Manual"
+ },
+ "extractionMethodTooltip": {
+ "text_extraction": "Extracted directly from PDF (fast, plain text only)",
+ "ocr": "Extracted using AI vision (preserves formatting: bold, headings, paragraphs)",
+ "manual": "Manually transcribed by user"
+ },
+ "insertFootnoteTooltip": {
+ "enterEditFirst": "Enter edit mode first",
+ "noFootnotes": "No footnotes exist yet",
+ "allReferenced": "All footnotes already referenced",
+ "insert": "Insert footnote reference"
+ },
+ "language": {
+ "notDetected": "Not detected",
+ "en": "English",
+ "es": "Spanish",
+ "fr": "French",
+ "de": "German",
+ "it": "Italian",
+ "pt": "Portuguese",
+ "ru": "Russian",
+ "zh": "Chinese",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "ar": "Arabic",
+ "hi": "Hindi",
+ "ro": "Romanian",
+ "nl": "Dutch",
+ "pl": "Polish",
+ "sv": "Swedish",
+ "da": "Danish",
+ "no": "Norwegian",
+ "fi": "Finnish",
+ "cs": "Czech",
+ "el": "Greek",
+ "tr": "Turkish",
+ "he": "Hebrew",
+ "th": "Thai",
+ "vi": "Vietnamese",
+ "id": "Indonesian",
+ "ms": "Malay",
+ "la": "Latin",
+ "uk": "Ukrainian",
+ "hu": "Hungarian",
+ "bg": "Bulgarian",
+ "hr": "Croatian",
+ "sk": "Slovak",
+ "sl": "Slovenian",
+ "sr": "Serbian",
+ "unknown": "Unknown"
+ },
+ "confirm": {
+ "clearTitle": "Clear text",
+ "clearMessage": "Delete extracted text for this page?",
+ "clearConfirm": "Clear",
+ "deleteFootnoteTitle": "Delete footnote",
+ "deleteFootnoteMessage": "Delete footnote [^{number}]? References will be removed from page texts.",
+ "deleteFootnoteConfirm": "Delete",
+ "unlinkFootnoteTitle": "Unlink footnote",
+ "unlinkFootnoteMessage": "Remove [^{number}] reference from page {page} text?",
+ "unlinkFootnoteConfirm": "Unlink"
+ },
+ "errors": {
+ "saveFailed": "Failed to save changes",
+ "unlinkOrphanFailed": "Failed to unlink orphaned footnotes",
+ "ignoreStatusFailed": "Failed to update page ignore status",
+ "clearFailed": "Failed to clear text",
+ "loadImageFailed": "Failed to load page image",
+ "loadDocumentFailed": "Failed to load document",
+ "processOcrFailed": "Failed to process OCR",
+ "extractTextFailed": "Failed to extract text",
+ "footnoteEmpty": "Footnote text cannot be empty",
+ "createFootnoteFailed": "Failed to create footnote",
+ "updateFootnoteFailed": "Failed to update footnote",
+ "deleteFootnoteFailed": "Failed to delete footnote",
+ "unlinkFootnoteFailed": "Failed to unlink footnote"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/extraction/ro.json b/wordkeep-client/public/assets/i18n/extraction/ro.json
new file mode 100644
index 0000000..adbb216
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/extraction/ro.json
@@ -0,0 +1,179 @@
+{
+ "list": {
+ "title": "Extragere text",
+ "subtitle": "Extrage text din documentele tale PDF folosind OCR",
+ "yourDocuments": "Documentele tale",
+ "docCount": "{count, plural, one {# document} few {# documente} other {# de documente}}",
+ "loading": "Se încarcă documentele...",
+ "emptyTitle": "Niciun document încă",
+ "emptySubtitle": "Încarcă mai întâi documente în secțiunea Documente",
+ "renamed": "redenumit",
+ "originalTitle": "Original: {name}",
+ "pageCount": "{count, plural, one {# pagină} few {# pagini} other {# de pagini}}",
+ "ocrProgress": "{completed}/{total} extrase ({percent}%)",
+ "cancelOcr": "Anulează OCR",
+ "overrideTitle": "Rulează din nou OCR pe paginile deja procesate",
+ "override": "Suprascrie",
+ "runAllTitle": "Rulează AI OCR pe toate paginile",
+ "runAll": "AI OCR pe tot",
+ "previous": "Anterioara",
+ "pageOf": "Pagina {current} din {last}",
+ "next": "Următoarea",
+ "errors": {
+ "loadFailed": "Încărcarea documentelor a eșuat"
+ }
+ },
+ "reader": {
+ "loading": "Se încarcă documentul...",
+ "notFoundTitle": "Documentul nu a fost găsit",
+ "backToExtraction": "Înapoi la Extragere text",
+ "demoLocked": "Disponibil după înregistrare",
+ "renamed": "redenumit",
+ "originalTitle": "Original: {name}",
+ "pages": "{count} pagini",
+ "detecting": "Se detectează...",
+ "languageInfo": "Limba este folosită pentru verificarea ortografică în timpul extragerii textului",
+ "pageHeader": "Pagina {current} din {total}",
+ "ignoredBadge": "Ignorată",
+ "ignoredBadgeTitle": "Această pagină este ignorată. Nu va fi inclusă în documentul exportat.",
+ "unignoreTitle": "Nu mai ignora această pagină",
+ "ignoreTitle": "Ignoră această pagină (exclude din documentul exportat)",
+ "unignore": "Nu mai ignora",
+ "ignore": "Ignoră",
+ "firstUnprocessed": "Prima neprocesată",
+ "firstUnprocessedTitle": "Mergi la prima pagină fără text",
+ "nextUnprocessed": "Următoarea neprocesată",
+ "nextUnprocessedTitle": "Mergi la următoarea pagină fără text de la cea curentă",
+ "pageAlt": "Pagina {page}",
+ "loadingPage": "Se încarcă pagina...",
+ "imageError": "Încărcarea imaginii paginii a eșuat",
+ "extractedText": "Text extras",
+ "edit": "Editează",
+ "clear": "Șterge",
+ "extracting": "Se extrage...",
+ "extractText": "Extrage text",
+ "processing": "Se procesează...",
+ "aiOcr": "AI OCR",
+ "transcribe": "Transcrie",
+ "cancel": "Anulează",
+ "saving": "Se salvează...",
+ "save": "Salvează",
+ "ignoredHeading": "Această pagină este ignorată",
+ "ignoredHint": "Nu va apărea în documentul exportat. Apasă „Nu mai ignora” pentru a reactiva extragerea textului.",
+ "editorPlaceholder": "Introdu text...",
+ "processingHeading": "Se procesează pagina cu AI OCR...",
+ "processingHint": "Poate dura un moment",
+ "extractingHeading": "Se extrage textul din PDF...",
+ "extractingHint": "Ar trebui să dureze puțin",
+ "cached": "Rezultat din cache",
+ "ocrFailed": "Procesarea OCR a eșuat",
+ "noText": "Niciun text disponibil",
+ "noTextHint": "Folosește butoanele de mai sus sau transcrie manual",
+ "transcribeManually": "Transcrie manual",
+ "footnotes": "Note de subsol",
+ "footnotesCounter": "din {total}",
+ "deleteFootnote": "Șterge",
+ "unlink": "Dezleagă",
+ "addFootnote": "Adaugă notă",
+ "footnotePlaceholder": "Introdu textul notei...",
+ "loadingFootnotes": "Se încarcă notele...",
+ "footnoteOnPage": "Această notă este pe pagina {page}",
+ "goToPage": "Mergi la pagina {page}",
+ "footnotePageBadge": "Pagina {page}",
+ "footnoteUnlinked": "Nelegată",
+ "noFootnotes": "Nicio notă încă",
+ "noFootnotesHint": "Apasă „Adaugă notă” pentru a crea una",
+ "contentType": {
+ "text": "Text",
+ "image": "Scanată",
+ "hybrid": "Mixtă",
+ "unknown": "Necunoscut"
+ },
+ "contentTypeTooltip": {
+ "text": "Pagină text. „Extrage text” = rapid, text simplu. „AI OCR” = mai lent, Markdown formatat.",
+ "image": "Pagină scanată. Folosește „AI OCR” pentru a extrage text cu viziune AI.",
+ "hybrid": "Pagină mixtă (text + imagini). „Extrage text” = rapid, text simplu. „AI OCR” = formatat cu conținut din imagini."
+ },
+ "extractionMethod": {
+ "text_extraction": "Direct",
+ "ocr": "OCR",
+ "manual": "Manual"
+ },
+ "extractionMethodTooltip": {
+ "text_extraction": "Extras direct din PDF (rapid, doar text simplu)",
+ "ocr": "Extras cu viziune AI (păstrează formatarea: îngroșat, titluri, paragrafe)",
+ "manual": "Transcris manual de utilizator"
+ },
+ "insertFootnoteTooltip": {
+ "enterEditFirst": "Intră mai întâi în modul editare",
+ "noFootnotes": "Nu există încă note de subsol",
+ "allReferenced": "Toate notele sunt deja referite",
+ "insert": "Inserează referință la notă"
+ },
+ "language": {
+ "notDetected": "Nedetectată",
+ "en": "Engleză",
+ "es": "Spaniolă",
+ "fr": "Franceză",
+ "de": "Germană",
+ "it": "Italiană",
+ "pt": "Portugheză",
+ "ru": "Rusă",
+ "zh": "Chineză",
+ "ja": "Japoneză",
+ "ko": "Coreeană",
+ "ar": "Arabă",
+ "hi": "Hindi",
+ "ro": "Română",
+ "nl": "Olandeză",
+ "pl": "Poloneză",
+ "sv": "Suedeză",
+ "da": "Daneză",
+ "no": "Norvegiană",
+ "fi": "Finlandeză",
+ "cs": "Cehă",
+ "el": "Greacă",
+ "tr": "Turcă",
+ "he": "Ebraică",
+ "th": "Thailandeză",
+ "vi": "Vietnameză",
+ "id": "Indoneziană",
+ "ms": "Malaeză",
+ "la": "Latină",
+ "uk": "Ucraineană",
+ "hu": "Maghiară",
+ "bg": "Bulgară",
+ "hr": "Croată",
+ "sk": "Slovacă",
+ "sl": "Slovenă",
+ "sr": "Sârbă",
+ "unknown": "Necunoscut"
+ },
+ "confirm": {
+ "clearTitle": "Șterge textul",
+ "clearMessage": "Ștergi textul extras pentru această pagină?",
+ "clearConfirm": "Șterge",
+ "deleteFootnoteTitle": "Șterge nota",
+ "deleteFootnoteMessage": "Ștergi nota [^{number}]? Referințele vor fi eliminate din textele paginilor.",
+ "deleteFootnoteConfirm": "Șterge",
+ "unlinkFootnoteTitle": "Dezleagă nota",
+ "unlinkFootnoteMessage": "Elimini referința [^{number}] din textul paginii {page}?",
+ "unlinkFootnoteConfirm": "Dezleagă"
+ },
+ "errors": {
+ "saveFailed": "Salvarea modificărilor a eșuat",
+ "unlinkOrphanFailed": "Dezlegarea notelor orfane a eșuat",
+ "ignoreStatusFailed": "Actualizarea stării de ignorare a paginii a eșuat",
+ "clearFailed": "Ștergerea textului a eșuat",
+ "loadImageFailed": "Încărcarea imaginii paginii a eșuat",
+ "loadDocumentFailed": "Încărcarea documentului a eșuat",
+ "processOcrFailed": "Procesarea OCR a eșuat",
+ "extractTextFailed": "Extragerea textului a eșuat",
+ "footnoteEmpty": "Textul notei nu poate fi gol",
+ "createFootnoteFailed": "Crearea notei a eșuat",
+ "updateFootnoteFailed": "Actualizarea notei a eșuat",
+ "deleteFootnoteFailed": "Ștergerea notei a eșuat",
+ "unlinkFootnoteFailed": "Dezlegarea notei a eșuat"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/home/en.json b/wordkeep-client/public/assets/i18n/home/en.json
new file mode 100644
index 0000000..825d061
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/home/en.json
@@ -0,0 +1,119 @@
+{
+ "brand": "WordKeep",
+ "hero": {
+ "eyebrow": "The library",
+ "greeting": "Welcome back, {{name}}",
+ "subtitle": "Pick up where you left off, or open a new chapter."
+ },
+ "tools": {
+ "eyebrow": "The tools",
+ "hint": "scroll →"
+ },
+ "cards": {
+ "documents": {
+ "title": "Documents",
+ "lede": "Upload, organise and curate the PDFs you keep.",
+ "cue": "Open the stacks →"
+ },
+ "extraction": {
+ "title": "Text extraction",
+ "lede": "Lift words off the page with OCR.",
+ "cue": "Extract →"
+ },
+ "pageLinking": {
+ "title": "Page linking",
+ "lede": "Stitch text across consecutive pages.",
+ "cue": "Link →"
+ },
+ "documentView": {
+ "title": "Document view",
+ "lede": "Read assembled text and export as Markdown.",
+ "cue": "Read →"
+ },
+ "translation": {
+ "title": "Translation",
+ "lede": "Carry your documents into another language.",
+ "cue": "Translate →"
+ }
+ },
+ "compendium": {
+ "bulletin": "Today's bulletin",
+ "fromEditor": "From the editor",
+ "inThisIssue": "In this issue",
+ "wireRecent": "Wire — Recent",
+ "cards": {
+ "documents": {
+ "section": "Circulation",
+ "lede": "Upload, organise and curate the PDFs you keep — the daily files that fund the editorial.",
+ "cue": "page 02 →"
+ },
+ "extraction": {
+ "section": "Press Room",
+ "lede": "OCR engines lift words from scanned plates; verified columns ready for the next desk.",
+ "cue": "page 03 →"
+ },
+ "pageLinking": {
+ "section": "Bindery",
+ "lede": "Stitch text across consecutive pages so paragraphs run unbroken from leaf to leaf.",
+ "cue": "page 04 →"
+ },
+ "documentView": {
+ "section": "Reading Room",
+ "lede": "Read assembled text and export as Markdown — the finished page, fit for syndication.",
+ "cue": "page 05 →"
+ },
+ "translation": {
+ "section": "Translation Desk",
+ "lede": "Carry your documents into another language; the translation desk files the foreign edition.",
+ "cue": "page 06 →"
+ }
+ }
+ },
+ "scriptorium": {
+ "rubricFolio": "Folium primum · the library",
+ "salutation": "Welcome back, {{name}} — pick up where the quill last left off, or open a fresh leaf and begin a new chapter.",
+ "rubricCapitula": "Capitula · tools at hand",
+ "rubricChronicle": "Chronicon · recently entered",
+ "cards": {
+ "documents": {
+ "title": "On the keeping of Documents ",
+ "lede": "Upload, organise & curate the PDFs you keep."
+ },
+ "extraction": {
+ "title": "On the lifting of Words ",
+ "lede": "The OCR scribe takes them off the page."
+ },
+ "pageLinking": {
+ "title": "On the binding of Pages ",
+ "lede": "Stitch text across consecutive folios."
+ },
+ "documentView": {
+ "title": "On the reading of the Codex ",
+ "lede": "Read the assembled text and export as Markdown."
+ },
+ "translation": {
+ "title": "On Translation & the carrying-over",
+ "lede": "Carry your documents into another tongue."
+ }
+ }
+ },
+ "console": {
+ "meta": "{{today}} · uid:{{name}}",
+ "name": "NAME",
+ "nameBody": " wordkeep — keep the words you read",
+ "synopsis": "SYNOPSIS",
+ "description": "DESCRIPTION",
+ "descriptionBody": " A vocabulary & OCR workbench for the readers who want\n every word they meet to be kept. Upload PDFs, lift text\n with OCR, stitch consecutive pages, translate, look up.\n\n Press / at any time to jump between sections.",
+ "open": "open ›",
+ "cards": {
+ "documents": "Upload, organise, curate the PDFs you keep.",
+ "extraction": "Lift words off the page with the OCR scribe.",
+ "pageLinking": "Bind text across consecutive pages.",
+ "documentView": "Read assembled text and export Markdown.",
+ "translation": "Carry your documents into another tongue."
+ }
+ },
+ "activity": {
+ "eyebrow": "Recent"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/home/ro.json b/wordkeep-client/public/assets/i18n/home/ro.json
new file mode 100644
index 0000000..34162fe
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/home/ro.json
@@ -0,0 +1,119 @@
+{
+ "brand": "WordKeep",
+ "hero": {
+ "eyebrow": "Biblioteca",
+ "greeting": "Bine ai revenit, {{name}}",
+ "subtitle": "Continuă de unde ai rămas sau deschide un nou capitol."
+ },
+ "tools": {
+ "eyebrow": "Uneltele",
+ "hint": "derulează →"
+ },
+ "cards": {
+ "documents": {
+ "title": "Documente",
+ "lede": "Încarcă, organizează și îngrijește PDF-urile pe care le păstrezi.",
+ "cue": "Deschide rafturile →"
+ },
+ "extraction": {
+ "title": "Extragere de text",
+ "lede": "Ridică cuvintele de pe pagină cu OCR.",
+ "cue": "Extrage →"
+ },
+ "pageLinking": {
+ "title": "Legare de pagini",
+ "lede": "Coase textul de-a lungul paginilor consecutive.",
+ "cue": "Leagă →"
+ },
+ "documentView": {
+ "title": "Vizualizare document",
+ "lede": "Citește textul asamblat și exportă-l ca Markdown.",
+ "cue": "Citește →"
+ },
+ "translation": {
+ "title": "Traducere",
+ "lede": "Du-ți documentele într-o altă limbă.",
+ "cue": "Tradu →"
+ }
+ },
+ "compendium": {
+ "bulletin": "Buletinul de azi",
+ "fromEditor": "De la redactor",
+ "inThisIssue": "În acest număr",
+ "wireRecent": "Flux — Recent",
+ "cards": {
+ "documents": {
+ "section": "Circulație",
+ "lede": "Încarcă, organizează și îngrijește PDF-urile pe care le păstrezi — fișierele zilnice care susțin editorialul.",
+ "cue": "pagina 02 →"
+ },
+ "extraction": {
+ "section": "Tipografia",
+ "lede": "Motoarele OCR ridică cuvintele de pe plăcile scanate; coloane verificate, gata pentru următorul birou.",
+ "cue": "pagina 03 →"
+ },
+ "pageLinking": {
+ "section": "Legătoria",
+ "lede": "Coase textul de-a lungul paginilor consecutive, ca paragrafele să curgă neîntrerupt de la o filă la alta.",
+ "cue": "pagina 04 →"
+ },
+ "documentView": {
+ "section": "Sala de lectură",
+ "lede": "Citește textul asamblat și exportă-l ca Markdown — pagina finită, gata de difuzare.",
+ "cue": "pagina 05 →"
+ },
+ "translation": {
+ "section": "Biroul de traduceri",
+ "lede": "Du-ți documentele într-o altă limbă; biroul de traduceri depune ediția străină.",
+ "cue": "pagina 06 →"
+ }
+ }
+ },
+ "scriptorium": {
+ "rubricFolio": "Folium primum · biblioteca",
+ "salutation": "Bine ai revenit, {{name}} — continuă de unde a rămas ultima oară pana, sau deschide o filă nouă și începe un nou capitol.",
+ "rubricCapitula": "Capitula · uneltele la îndemână",
+ "rubricChronicle": "Chronicon · intrate recent",
+ "cards": {
+ "documents": {
+ "title": "Despre păstrarea Documentelor ",
+ "lede": "Încarcă, organizează & îngrijește PDF-urile pe care le păstrezi."
+ },
+ "extraction": {
+ "title": "Despre ridicarea Cuvintelor ",
+ "lede": "Scribul OCR le ridică de pe pagină."
+ },
+ "pageLinking": {
+ "title": "Despre legarea Paginilor ",
+ "lede": "Coase textul de-a lungul foilor consecutive."
+ },
+ "documentView": {
+ "title": "Despre citirea Codexului ",
+ "lede": "Citește textul asamblat și exportă-l ca Markdown."
+ },
+ "translation": {
+ "title": "Despre Traducere & trecerea dintr-o limbă în alta",
+ "lede": "Du-ți documentele într-o altă limbă."
+ }
+ }
+ },
+ "console": {
+ "meta": "{{today}} · uid:{{name}}",
+ "name": "NUME",
+ "nameBody": " wordkeep — păstrează cuvintele pe care le citești",
+ "synopsis": "REZUMAT",
+ "description": "DESCRIERE",
+ "descriptionBody": " Un atelier de vocabular & OCR pentru cititorii care vor\n ca fiecare cuvânt întâlnit să fie păstrat. Încarcă PDF-uri, ridică text\n cu OCR, coase pagini consecutive, tradu, caută.\n\n Apasă / oricând pentru a sări între secțiuni.",
+ "open": "deschide ›",
+ "cards": {
+ "documents": "Încarcă, organizează, îngrijește PDF-urile pe care le păstrezi.",
+ "extraction": "Ridică cuvintele de pe pagină cu scribul OCR.",
+ "pageLinking": "Leagă textul de-a lungul paginilor consecutive.",
+ "documentView": "Citește textul asamblat și exportă Markdown.",
+ "translation": "Du-ți documentele într-o altă limbă."
+ }
+ },
+ "activity": {
+ "eyebrow": "Recent"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/pageLinking/en.json b/wordkeep-client/public/assets/i18n/pageLinking/en.json
new file mode 100644
index 0000000..7eca8e5
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/pageLinking/en.json
@@ -0,0 +1,51 @@
+{
+ "list": {
+ "title": "Page Linking",
+ "subtitle": "Link consecutive pages to define how text flows between them",
+ "errorLoad": "Failed to load documents",
+ "documentsHeading": "Your Documents",
+ "docCount": "{{count}} document",
+ "docCountPlural": "{{count}} documents",
+ "loading": "Loading documents...",
+ "emptyTitle": "No documents ready",
+ "emptySubtitle": "Only ready documents with more than one page appear here",
+ "renamedBadge": "renamed",
+ "originalTitle": "Original: {{name}}",
+ "pages": "{{count}} pages",
+ "progressText": "{{completed}} / {{total}} pages linked",
+ "linkAction": "Link",
+ "previous": "Previous",
+ "next": "Next",
+ "pageInfo": "Page {{current}} of {{last}}"
+ },
+ "reader": {
+ "loading": "Loading document...",
+ "backLink": "Back to Page Linking",
+ "demoLocked": "Available after you sign up",
+ "pairIndicator": "Pair {{current}} / {{total}}",
+ "errorLoadDocument": "Failed to load document",
+ "errorLoadPageData": "Failed to load page data",
+ "errorSave": "Failed to save. Please try again.",
+ "errorIgnore": "Failed to update page ignore status",
+ "prev": "Prev",
+ "next": "Next",
+ "firstUnprocessed": "First Unprocessed",
+ "nextUnprocessed": "Next Unprocessed",
+ "allDone": "All done! All page pairs have been linked.",
+ "pageLabel": "Page {{page}}",
+ "ignore": "Ignore",
+ "unignore": "Un-ignore",
+ "ignorePage": "Ignore page {{page}}",
+ "unignorePage": "Un-ignore page {{page}}",
+ "pageAlt": "Page {{page}}",
+ "ignoredOverlay": "Ignored",
+ "imageUnavailable": "Image unavailable",
+ "ignoredPairTooltip": "One or more pages in this pair are ignored. Un-ignore the page to set a link decision.",
+ "ignoredLabel": "IGNORED",
+ "newRow": "New Row",
+ "continueRow": "Continue Row",
+ "noSelectionHint": "No selection — pair will remain unlinked",
+ "saving": "Saving...",
+ "saveAndContinue": "Save & Continue"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/pageLinking/ro.json b/wordkeep-client/public/assets/i18n/pageLinking/ro.json
new file mode 100644
index 0000000..f4341c9
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/pageLinking/ro.json
@@ -0,0 +1,51 @@
+{
+ "list": {
+ "title": "Legarea paginilor",
+ "subtitle": "Leagă paginile consecutive pentru a defini cum se continuă textul între ele",
+ "errorLoad": "Încărcarea documentelor a eșuat",
+ "documentsHeading": "Documentele tale",
+ "docCount": "{{count}} document",
+ "docCountPlural": "{{count}} documente",
+ "loading": "Se încarcă documentele...",
+ "emptyTitle": "Niciun document pregătit",
+ "emptySubtitle": "Aici apar doar documentele pregătite cu mai mult de o pagină",
+ "renamedBadge": "redenumit",
+ "originalTitle": "Original: {{name}}",
+ "pages": "{{count}} pagini",
+ "progressText": "{{completed}} / {{total}} pagini legate",
+ "linkAction": "Leagă",
+ "previous": "Anterior",
+ "next": "Următor",
+ "pageInfo": "Pagina {{current}} din {{last}}"
+ },
+ "reader": {
+ "loading": "Se încarcă documentul...",
+ "backLink": "Înapoi la Legarea paginilor",
+ "demoLocked": "Disponibil după înregistrare",
+ "pairIndicator": "Perechea {{current}} / {{total}}",
+ "errorLoadDocument": "Încărcarea documentului a eșuat",
+ "errorLoadPageData": "Încărcarea datelor paginii a eșuat",
+ "errorSave": "Salvarea a eșuat. Încearcă din nou.",
+ "errorIgnore": "Actualizarea stării de ignorare a paginii a eșuat",
+ "prev": "Anterior",
+ "next": "Următor",
+ "firstUnprocessed": "Prima neprocesată",
+ "nextUnprocessed": "Următoarea neprocesată",
+ "allDone": "Gata! Toate perechile de pagini au fost legate.",
+ "pageLabel": "Pagina {{page}}",
+ "ignore": "Ignoră",
+ "unignore": "Nu ignora",
+ "ignorePage": "Ignoră pagina {{page}}",
+ "unignorePage": "Nu ignora pagina {{page}}",
+ "pageAlt": "Pagina {{page}}",
+ "ignoredOverlay": "Ignorată",
+ "imageUnavailable": "Imagine indisponibilă",
+ "ignoredPairTooltip": "Una sau mai multe pagini din această pereche sunt ignorate. Nu mai ignora pagina pentru a stabili o decizie de legare.",
+ "ignoredLabel": "IGNORATĂ",
+ "newRow": "Rând nou",
+ "continueRow": "Continuă rândul",
+ "noSelectionHint": "Nicio selecție — perechea va rămâne nelegată",
+ "saving": "Se salvează...",
+ "saveAndContinue": "Salvează & continuă"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/profile/en.json b/wordkeep-client/public/assets/i18n/profile/en.json
new file mode 100644
index 0000000..28bba79
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/profile/en.json
@@ -0,0 +1,167 @@
+{
+ "nav": {
+ "account": "Account",
+ "info": "Profile Info",
+ "security": "Account Security",
+ "personalisation": "Personalisation"
+ },
+ "personalisation": {
+ "title": "Personalisation",
+ "subtitle": "Pick a layout, a palette, a colour mode, and a language.",
+ "layoutHeading": "Layout",
+ "paletteHeading": "Palette",
+ "modeHeading": "Colour mode",
+ "resolvedPrefix": "resolved",
+ "mode": {
+ "auto": "Auto",
+ "light": "Light",
+ "dark": "Dark"
+ },
+ "layouts": {
+ "reading-room": { "label": "Reading Room", "blurb": "Editorial baseline — top header, gentle shelves." },
+ "workbench": { "label": "Workbench", "blurb": "Slim top bar, bottom dock — a pro tool." },
+ "compendium": { "label": "Compendium", "blurb": "Tall masthead, newspaper composition." },
+ "glass": { "label": "Glass", "blurb": "Floating capsule, content owns the canvas." },
+ "scriptorium": { "label": "Scriptorium", "blurb": "Manuscript folio — drop cap, ruled vellum, rubric." },
+ "console": { "label": "Console", "blurb": "Terminal — prompt line, mono rows, slash palette." }
+ },
+ "palettes": {
+ "reading-room": { "label": "Reading Room", "blurb": "Cream + wine + brass" },
+ "cobalt": { "label": "Cobalt", "blurb": "Bone + cobalt + vermilion" },
+ "monastic": { "label": "Monastic", "blurb": "Vellum + lapis + sepia" },
+ "grove": { "label": "Grove", "blurb": "Birch + moss + ochre" },
+ "ferrous": { "label": "Ferrous", "blurb": "Ash + rust + steel" },
+ "plum": { "label": "Plum", "blurb": "Alabaster + plum + gilt" }
+ },
+ "language": {
+ "title": "Language",
+ "description": "Choose the language for the WordKeep interface.",
+ "name": {
+ "en": "English",
+ "ro": "Romanian"
+ }
+ }
+ },
+ "info": {
+ "title": "Profile Info",
+ "avatar": {
+ "alt": "Profile photo",
+ "hintUpdate": "Update your profile photo.",
+ "hintAdd": "Add a profile photo.",
+ "changePhoto": "Change Photo",
+ "uploadPhoto": "Upload Photo",
+ "removing": "Removing…",
+ "remove": "Remove"
+ },
+ "name": {
+ "label": "Name",
+ "edit": "Edit",
+ "placeholder": "Your name",
+ "saving": "Saving…",
+ "save": "Save",
+ "cancel": "Cancel",
+ "required": "Name is required.",
+ "saveFailed": "Failed to save. Try again."
+ },
+ "email": {
+ "label": "Email",
+ "change": "Change",
+ "pending": "Pending change to {{email}} ",
+ "pendingHint": "Check your new inbox for the verification link. Expires {{date}}.",
+ "resent": "Verification email resent.",
+ "sending": "Sending…",
+ "resend": "Resend",
+ "cancelling": "Cancelling…",
+ "cancel": "Cancel"
+ },
+ "emailModal": {
+ "title": "Change Email",
+ "passwordDesc": "Enter your current password to confirm your identity.",
+ "currentPasswordLabel": "Current Password",
+ "currentPasswordPlaceholder": "Your current password",
+ "emailDesc": "Enter the new email address. A verification link will be sent to it.",
+ "newEmailLabel": "New Email",
+ "newEmailPlaceholder": "new@example.com",
+ "cancel": "Cancel",
+ "checking": "Checking…",
+ "continue": "Continue",
+ "sending": "Sending…",
+ "sendVerification": "Send Verification",
+ "passwordRequired": "Password is required.",
+ "incorrectPassword": "Incorrect password.",
+ "newEmailRequired": "New email is required.",
+ "requestFailed": "Failed to request email change. Try again."
+ }
+ },
+ "security": {
+ "title": "Account Security",
+ "passwordUpdated": "Password updated successfully.",
+ "password": {
+ "label": "Password",
+ "change": "Change"
+ },
+ "twoFactor": {
+ "label": "Two-Factor Authentication",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "desc": "Use an authenticator app (Google Authenticator, 1Password, etc.) to add an extra layer of security.",
+ "disable": "Disable",
+ "enable": "Enable"
+ },
+ "passwordModal": {
+ "title": "Change Password",
+ "currentDesc": "Enter your current password to confirm your identity.",
+ "currentLabel": "Current Password",
+ "currentPlaceholder": "Your current password",
+ "newDesc": "Choose a new password. It must be at least 8 characters.",
+ "newLabel": "New Password",
+ "newPlaceholder": "New password",
+ "confirmLabel": "Confirm New Password",
+ "confirmPlaceholder": "Confirm new password",
+ "cancel": "Cancel",
+ "checking": "Checking…",
+ "continue": "Continue",
+ "saving": "Saving…",
+ "savePassword": "Save Password",
+ "passwordRequired": "Password is required.",
+ "incorrectPassword": "Incorrect password.",
+ "newRequired": "New password is required.",
+ "noMatch": "Passwords do not match.",
+ "changeFailed": "Failed to change password. Try again."
+ },
+ "enable2fa": {
+ "title": "Enable Two-Factor Authentication",
+ "passwordDesc": "Enter your current password to confirm your identity.",
+ "passwordLabel": "Current Password",
+ "passwordPlaceholder": "Your current password",
+ "scanDesc": "Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.",
+ "manualSummary": "Can't scan? Enter manually",
+ "codeLabel": "Verification Code",
+ "codePlaceholder": "000000",
+ "enabledTitle": "Two-factor authentication enabled!",
+ "recoveryDesc": "Save these recovery codes somewhere safe. Each code can only be used once if you lose access to your authenticator app.",
+ "copyAll": "Copy all codes",
+ "cancel": "Cancel",
+ "checking": "Checking…",
+ "continue": "Continue",
+ "verifying": "Verifying…",
+ "confirm": "Confirm",
+ "done": "Done",
+ "passwordRequired": "Password is required.",
+ "incorrectPassword": "Incorrect password.",
+ "codeRequired": "Verification code is required.",
+ "invalidCode": "Invalid code."
+ },
+ "disable2fa": {
+ "title": "Disable Two-Factor Authentication",
+ "desc": "Enter your password to disable two-factor authentication. This will make your account less secure.",
+ "passwordLabel": "Current Password",
+ "passwordPlaceholder": "Your current password",
+ "cancel": "Cancel",
+ "disabling": "Disabling…",
+ "disable": "Disable 2FA",
+ "passwordRequired": "Password is required.",
+ "incorrectPassword": "Incorrect password."
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/profile/ro.json b/wordkeep-client/public/assets/i18n/profile/ro.json
new file mode 100644
index 0000000..cd186fd
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/profile/ro.json
@@ -0,0 +1,167 @@
+{
+ "nav": {
+ "account": "Cont",
+ "info": "Profil",
+ "security": "Securitatea contului",
+ "personalisation": "Personalizare"
+ },
+ "personalisation": {
+ "title": "Personalizare",
+ "subtitle": "Alege un aspect, o paletă, un mod de culoare și o limbă.",
+ "layoutHeading": "Aspect",
+ "paletteHeading": "Paletă",
+ "modeHeading": "Mod de culoare",
+ "resolvedPrefix": "rezolvat",
+ "mode": {
+ "auto": "Automat",
+ "light": "Luminos",
+ "dark": "Întunecat"
+ },
+ "layouts": {
+ "reading-room": { "label": "Sala de lectură", "blurb": "Bază editorială — antet sus, rafturi domoale." },
+ "workbench": { "label": "Banc de lucru", "blurb": "Bară subțire sus, doc jos — un instrument profesionist." },
+ "compendium": { "label": "Compendiu", "blurb": "Frontispiciu înalt, compoziție de ziar." },
+ "glass": { "label": "Sticlă", "blurb": "Capsulă plutitoare, conținutul stăpânește pânza." },
+ "scriptorium": { "label": "Scriptorium", "blurb": "Folio de manuscris — inițială ornată, velin liniat, rubrică." },
+ "console": { "label": "Consolă", "blurb": "Terminal — linie de comandă, rânduri mono, paletă slash." }
+ },
+ "palettes": {
+ "reading-room": { "label": "Sala de lectură", "blurb": "Crem + vin + alamă" },
+ "cobalt": { "label": "Cobalt", "blurb": "Os + cobalt + vermillon" },
+ "monastic": { "label": "Monahală", "blurb": "Velin + lapis + sepia" },
+ "grove": { "label": "Crâng", "blurb": "Mesteacăn + mușchi + ocru" },
+ "ferrous": { "label": "Feruginos", "blurb": "Cenușă + rugină + oțel" },
+ "plum": { "label": "Prună", "blurb": "Alabastru + prună + auriu" }
+ },
+ "language": {
+ "title": "Limbă",
+ "description": "Alege limba pentru interfața WordKeep.",
+ "name": {
+ "en": "Engleză",
+ "ro": "Română"
+ }
+ }
+ },
+ "info": {
+ "title": "Profil",
+ "avatar": {
+ "alt": "Fotografie de profil",
+ "hintUpdate": "Actualizează-ți fotografia de profil.",
+ "hintAdd": "Adaugă o fotografie de profil.",
+ "changePhoto": "Schimbă fotografia",
+ "uploadPhoto": "Încarcă fotografie",
+ "removing": "Se elimină…",
+ "remove": "Elimină"
+ },
+ "name": {
+ "label": "Nume",
+ "edit": "Editează",
+ "placeholder": "Numele tău",
+ "saving": "Se salvează…",
+ "save": "Salvează",
+ "cancel": "Anulează",
+ "required": "Numele este obligatoriu.",
+ "saveFailed": "Salvarea a eșuat. Încearcă din nou."
+ },
+ "email": {
+ "label": "E-mail",
+ "change": "Schimbă",
+ "pending": "Modificare în așteptare către {{email}} ",
+ "pendingHint": "Verifică noua căsuță de e-mail pentru linkul de verificare. Expiră {{date}}.",
+ "resent": "E-mailul de verificare a fost retrimis.",
+ "sending": "Se trimite…",
+ "resend": "Retrimite",
+ "cancelling": "Se anulează…",
+ "cancel": "Anulează"
+ },
+ "emailModal": {
+ "title": "Schimbă e-mailul",
+ "passwordDesc": "Introdu parola curentă pentru a-ți confirma identitatea.",
+ "currentPasswordLabel": "Parola curentă",
+ "currentPasswordPlaceholder": "Parola ta curentă",
+ "emailDesc": "Introdu noua adresă de e-mail. Un link de verificare va fi trimis la aceasta.",
+ "newEmailLabel": "E-mail nou",
+ "newEmailPlaceholder": "nou@exemplu.com",
+ "cancel": "Anulează",
+ "checking": "Se verifică…",
+ "continue": "Continuă",
+ "sending": "Se trimite…",
+ "sendVerification": "Trimite verificarea",
+ "passwordRequired": "Parola este obligatorie.",
+ "incorrectPassword": "Parolă incorectă.",
+ "newEmailRequired": "E-mailul nou este obligatoriu.",
+ "requestFailed": "Solicitarea de schimbare a e-mailului a eșuat. Încearcă din nou."
+ }
+ },
+ "security": {
+ "title": "Securitatea contului",
+ "passwordUpdated": "Parola a fost actualizată cu succes.",
+ "password": {
+ "label": "Parolă",
+ "change": "Schimbă"
+ },
+ "twoFactor": {
+ "label": "Autentificare în doi pași",
+ "enabled": "Activată",
+ "disabled": "Dezactivată",
+ "desc": "Folosește o aplicație de autentificare (Google Authenticator, 1Password etc.) pentru a adăuga un nivel suplimentar de securitate.",
+ "disable": "Dezactivează",
+ "enable": "Activează"
+ },
+ "passwordModal": {
+ "title": "Schimbă parola",
+ "currentDesc": "Introdu parola curentă pentru a-ți confirma identitatea.",
+ "currentLabel": "Parola curentă",
+ "currentPlaceholder": "Parola ta curentă",
+ "newDesc": "Alege o parolă nouă. Trebuie să aibă cel puțin 8 caractere.",
+ "newLabel": "Parolă nouă",
+ "newPlaceholder": "Parolă nouă",
+ "confirmLabel": "Confirmă parola nouă",
+ "confirmPlaceholder": "Confirmă parola nouă",
+ "cancel": "Anulează",
+ "checking": "Se verifică…",
+ "continue": "Continuă",
+ "saving": "Se salvează…",
+ "savePassword": "Salvează parola",
+ "passwordRequired": "Parola este obligatorie.",
+ "incorrectPassword": "Parolă incorectă.",
+ "newRequired": "Parola nouă este obligatorie.",
+ "noMatch": "Parolele nu se potrivesc.",
+ "changeFailed": "Schimbarea parolei a eșuat. Încearcă din nou."
+ },
+ "enable2fa": {
+ "title": "Activează autentificarea în doi pași",
+ "passwordDesc": "Introdu parola curentă pentru a-ți confirma identitatea.",
+ "passwordLabel": "Parola curentă",
+ "passwordPlaceholder": "Parola ta curentă",
+ "scanDesc": "Scanează acest cod QR cu aplicația de autentificare, apoi introdu codul din 6 cifre pentru a confirma.",
+ "manualSummary": "Nu poți scana? Introdu manual",
+ "codeLabel": "Cod de verificare",
+ "codePlaceholder": "000000",
+ "enabledTitle": "Autentificarea în doi pași a fost activată!",
+ "recoveryDesc": "Păstrează aceste coduri de recuperare într-un loc sigur. Fiecare cod poate fi folosit o singură dată dacă pierzi accesul la aplicația de autentificare.",
+ "copyAll": "Copiază toate codurile",
+ "cancel": "Anulează",
+ "checking": "Se verifică…",
+ "continue": "Continuă",
+ "verifying": "Se verifică…",
+ "confirm": "Confirmă",
+ "done": "Gata",
+ "passwordRequired": "Parola este obligatorie.",
+ "incorrectPassword": "Parolă incorectă.",
+ "codeRequired": "Codul de verificare este obligatoriu.",
+ "invalidCode": "Cod invalid."
+ },
+ "disable2fa": {
+ "title": "Dezactivează autentificarea în doi pași",
+ "desc": "Introdu parola pentru a dezactiva autentificarea în doi pași. Acest lucru îți va face contul mai puțin sigur.",
+ "passwordLabel": "Parola curentă",
+ "passwordPlaceholder": "Parola ta curentă",
+ "cancel": "Anulează",
+ "disabling": "Se dezactivează…",
+ "disable": "Dezactivează 2FA",
+ "passwordRequired": "Parola este obligatorie.",
+ "incorrectPassword": "Parolă incorectă."
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/ro.json b/wordkeep-client/public/assets/i18n/ro.json
new file mode 100644
index 0000000..f3ae828
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/ro.json
@@ -0,0 +1,60 @@
+{
+ "language": {
+ "label": "Limbă",
+ "en": "EN",
+ "ro": "RO",
+ "enName": "Engleză",
+ "roName": "Română",
+ "switch": "Schimbă limba"
+ },
+ "userMenu": {
+ "profileInfo": "Profil",
+ "accountSecurity": "Securitatea contului",
+ "personalisation": "Personalizare",
+ "dictionary": "Dicționarul meu",
+ "logout": "Deconectare"
+ },
+ "common": {
+ "save": "Salvează",
+ "cancel": "Anulează",
+ "close": "Închide",
+ "delete": "Șterge",
+ "edit": "Editează",
+ "loading": "Se încarcă…"
+ },
+ "confirmations": {
+ "deleteDocument": {
+ "title": "Șterge documentul",
+ "message": "Sigur vrei să ștergi „{{name}}”? Această acțiune nu poate fi anulată.",
+ "confirm": "Șterge"
+ },
+ "overrideOcrSingle": {
+ "title": "Reprocesează OCR",
+ "message": "Această pagină va fi reprocesată cu OCR.",
+ "warning": "Orice modificări manuale ale textului sau ale formatării de pe această pagină vor fi pierdute.",
+ "confirm": "Rulează OCR"
+ },
+ "overrideExtractTextSingle": {
+ "title": "Reextrage textul",
+ "message": "Textul va fi reextras de pe această pagină.",
+ "warning": "Orice modificări manuale ale textului sau ale formatării de pe această pagină vor fi pierdute.",
+ "confirm": "Extrage textul"
+ },
+ "overrideOcrBulk": {
+ "title": "Reprocesează OCR",
+ "message": "Toate paginile vor fi reprocesate cu OCR.",
+ "warning": "Orice modificări manuale ale textului sau ale formatării de pe toate paginile vor fi pierdute.",
+ "confirm": "Rulează OCR"
+ }
+ },
+ "languages": {
+ "ar": "Arabă", "bg": "Bulgară", "zh": "Chineză", "hr": "Croată", "cs": "Cehă",
+ "da": "Daneză", "nl": "Neerlandeză", "en": "Engleză", "fi": "Finlandeză", "fr": "Franceză",
+ "de": "Germană", "el": "Greacă", "he": "Ebraică", "hi": "Hindi", "hu": "Maghiară",
+ "id": "Indoneziană", "it": "Italiană", "ja": "Japoneză", "ko": "Coreeană", "la": "Latină",
+ "ms": "Malaeză", "no": "Norvegiană", "pl": "Poloneză", "pt": "Portugheză", "ro": "Română",
+ "ru": "Rusă", "sr": "Sârbă", "sk": "Slovacă", "sl": "Slovenă", "es": "Spaniolă",
+ "sv": "Suedeză", "th": "Thailandeză", "tr": "Turcă", "uk": "Ucraineană", "vi": "Vietnameză",
+ "unknown": "Necunoscută"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/shared/en.json b/wordkeep-client/public/assets/i18n/shared/en.json
new file mode 100644
index 0000000..335b9f0
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/shared/en.json
@@ -0,0 +1,74 @@
+{
+ "common": {
+ "cancel": "Cancel",
+ "save": "Save"
+ },
+ "sort": {
+ "label": "Sort:",
+ "lastModified": "Last modified",
+ "titleAsc": "Title (A→Z)",
+ "titleDesc": "Title (Z→A)"
+ },
+ "renameDialog": {
+ "title": "Rename Document",
+ "name": "Name",
+ "original": "Original: {{name}}",
+ "restore": "Restore Original"
+ },
+ "recentActivity": {
+ "title": "Recent Activity",
+ "loading": "Loading…",
+ "loadError": "Failed to load recent activity",
+ "empty": "No recent activity yet — open a document to get started.",
+ "page": "Page {{n}} / {{total}}",
+ "badge": {
+ "ocr": "OCR",
+ "pageLinking": "Page Linking",
+ "bookView": "Book View",
+ "translation": "Translation — {{lang}}"
+ },
+ "time": {
+ "justNow": "just now",
+ "minutes": "{{n}}m ago",
+ "hours": "{{n}}h ago",
+ "days": "{{n}}d ago"
+ }
+ },
+ "avatarCrop": {
+ "title": "Update Profile Photo",
+ "close": "Close",
+ "choosePhoto": "Click to choose a photo",
+ "fileHint": "JPEG, PNG or WEBP — max 5 MB",
+ "chooseDifferent": "Choose a different photo",
+ "save": "Save Photo",
+ "saving": "Saving…",
+ "errorTooLarge": "Image must be under 5 MB.",
+ "errorSave": "Failed to save avatar. Please try again."
+ },
+ "commandPalette": {
+ "ariaLabel": "Command palette",
+ "placeholder": "goto",
+ "enter": "enter",
+ "esc": "esc",
+ "empty": "no matches · esc to close",
+ "move": "move",
+ "open": "open",
+ "close": "close"
+ },
+ "newTranslation": {
+ "title": "New Translation",
+ "document": "Document",
+ "loadingDocuments": "Loading documents...",
+ "noDocuments": "No ready documents available.",
+ "selectDocument": "Select a document...",
+ "targetLanguage": "Target Language",
+ "selectLanguage": "Select a language...",
+ "add": "Add Translation"
+ },
+ "spellCheck": {
+ "showMore": "show {{n}} more",
+ "noSuggestions": "No suggestions",
+ "ignore": "Ignore",
+ "addToDictionary": "Add to dictionary"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/shared/ro.json b/wordkeep-client/public/assets/i18n/shared/ro.json
new file mode 100644
index 0000000..70a0d4e
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/shared/ro.json
@@ -0,0 +1,74 @@
+{
+ "common": {
+ "cancel": "Anulează",
+ "save": "Salvează"
+ },
+ "sort": {
+ "label": "Sortează:",
+ "lastModified": "Ultima modificare",
+ "titleAsc": "Titlu (A→Z)",
+ "titleDesc": "Titlu (Z→A)"
+ },
+ "renameDialog": {
+ "title": "Redenumește documentul",
+ "name": "Nume",
+ "original": "Original: {{name}}",
+ "restore": "Restaurează originalul"
+ },
+ "recentActivity": {
+ "title": "Activitate recentă",
+ "loading": "Se încarcă…",
+ "loadError": "Încărcarea activității recente a eșuat",
+ "empty": "Nicio activitate recentă încă — deschide un document pentru a începe.",
+ "page": "Pagina {{n}} / {{total}}",
+ "badge": {
+ "ocr": "OCR",
+ "pageLinking": "Legare pagini",
+ "bookView": "Vizualizare carte",
+ "translation": "Traducere — {{lang}}"
+ },
+ "time": {
+ "justNow": "chiar acum",
+ "minutes": "acum {{n}}m",
+ "hours": "acum {{n}}h",
+ "days": "acum {{n}}z"
+ }
+ },
+ "avatarCrop": {
+ "title": "Actualizează fotografia de profil",
+ "close": "Închide",
+ "choosePhoto": "Apasă pentru a alege o fotografie",
+ "fileHint": "JPEG, PNG sau WEBP — maxim 5 MB",
+ "chooseDifferent": "Alege altă fotografie",
+ "save": "Salvează fotografia",
+ "saving": "Se salvează…",
+ "errorTooLarge": "Imaginea trebuie să fie sub 5 MB.",
+ "errorSave": "Salvarea avatarului a eșuat. Încearcă din nou."
+ },
+ "commandPalette": {
+ "ariaLabel": "Paletă de comenzi",
+ "placeholder": "mergi la",
+ "enter": "enter",
+ "esc": "esc",
+ "empty": "nicio potrivire · esc pentru a închide",
+ "move": "navighează",
+ "open": "deschide",
+ "close": "închide"
+ },
+ "newTranslation": {
+ "title": "Traducere nouă",
+ "document": "Document",
+ "loadingDocuments": "Se încarcă documentele...",
+ "noDocuments": "Niciun document disponibil.",
+ "selectDocument": "Selectează un document...",
+ "targetLanguage": "Limba țintă",
+ "selectLanguage": "Selectează o limbă...",
+ "add": "Adaugă traducere"
+ },
+ "spellCheck": {
+ "showMore": "afișează încă {{n}}",
+ "noSuggestions": "Nicio sugestie",
+ "ignore": "Ignoră",
+ "addToDictionary": "Adaugă în dicționar"
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/shell/en.json b/wordkeep-client/public/assets/i18n/shell/en.json
new file mode 100644
index 0000000..d301eb1
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/shell/en.json
@@ -0,0 +1,50 @@
+{
+ "brand": "WordKeep",
+ "ariaHome": "WordKeep home",
+ "nav": {
+ "ariaPrimary": "Primary",
+ "dashboard": "Dashboard",
+ "documents": "Documents",
+ "textExtraction": "Text Extraction",
+ "extraction": "Extraction",
+ "pageLinking": "Page Linking",
+ "linking": "Linking",
+ "documentView": "Document View",
+ "view": "View",
+ "translation": "Translation"
+ },
+ "compendium": {
+ "edition": "Vol. I — Daily Edition",
+ "price": "No. 35",
+ "frontPage": "Front Page",
+ "circulation": "Circulation",
+ "pressRoom": "Press Room",
+ "bindery": "Bindery",
+ "readingRoom": "Reading Room",
+ "translationDesk": "Translation Desk"
+ },
+ "console": {
+ "promptUser": "wordkeep",
+ "docs": "docs",
+ "extr": "extr",
+ "link": "link",
+ "viewNav": "view",
+ "trsl": "trsl",
+ "dict": "dict",
+ "openPalette": "Open command palette",
+ "statusBrand": "[wordkeep]",
+ "navHint": "press / to navigate",
+ "palette": {
+ "reading-room": "Reading Room",
+ "cobalt": "Cobalt",
+ "monastic": "Monastic",
+ "grove": "Grove",
+ "ferrous": "Ferrous",
+ "plum": "Plum"
+ },
+ "theme": {
+ "light": "Light",
+ "dark": "Dark"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/shell/ro.json b/wordkeep-client/public/assets/i18n/shell/ro.json
new file mode 100644
index 0000000..976a0e2
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/shell/ro.json
@@ -0,0 +1,50 @@
+{
+ "brand": "WordKeep",
+ "ariaHome": "Pagina principală WordKeep",
+ "nav": {
+ "ariaPrimary": "Principal",
+ "dashboard": "Panou",
+ "documents": "Documente",
+ "textExtraction": "Extragere text",
+ "extraction": "Extragere",
+ "pageLinking": "Asociere pagini",
+ "linking": "Asociere",
+ "documentView": "Vizualizare document",
+ "view": "Vizualizare",
+ "translation": "Traducere"
+ },
+ "compendium": {
+ "edition": "Vol. I — Ediția zilnică",
+ "price": "Nr. 35",
+ "frontPage": "Prima pagină",
+ "circulation": "Difuzare",
+ "pressRoom": "Tipografie",
+ "bindery": "Legătorie",
+ "readingRoom": "Sala de lectură",
+ "translationDesk": "Biroul de traduceri"
+ },
+ "console": {
+ "promptUser": "wordkeep",
+ "docs": "doc",
+ "extr": "extr",
+ "link": "leg",
+ "viewNav": "viz",
+ "trsl": "trad",
+ "dict": "dicț",
+ "openPalette": "Deschide paleta de comenzi",
+ "statusBrand": "[wordkeep]",
+ "navHint": "apăsați / pentru a naviga",
+ "palette": {
+ "reading-room": "Sala de lectură",
+ "cobalt": "Cobalt",
+ "monastic": "Monahală",
+ "grove": "Crâng",
+ "ferrous": "Feruginos",
+ "plum": "Prună"
+ },
+ "theme": {
+ "light": "Luminos",
+ "dark": "Întunecat"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/translation/en.json b/wordkeep-client/public/assets/i18n/translation/en.json
new file mode 100644
index 0000000..f1893cc
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/translation/en.json
@@ -0,0 +1,160 @@
+{
+ "list": {
+ "title": "Translation",
+ "subtitle": "Translate your documents using AI.",
+ "newTranslation": "New Translation",
+ "yourTranslations": "Your Translations",
+ "count": "{{count}} translation",
+ "countPlural": "{{count}} translations",
+ "loading": "Loading translations...",
+ "emptyTitle": "No translations yet",
+ "emptyBody": "Click \"New Translation\" to translate a document",
+ "documentFallback": "Document #{{id}}",
+ "wrongLangPages": "{{count}} pages with wrong language",
+ "wrongLangFootnotes": "{{count}} footnotes with wrong language",
+ "page": "{{count}} page",
+ "pagePlural": "{{count}} pages",
+ "translatedProgress": "{{done}}/{{total}} translated ({{percent}}%)",
+ "overrideTitle": "Re-translate pages that have already been translated",
+ "override": "Override",
+ "translateAllTitle": "Translate all pages",
+ "translateAll": "Translate All",
+ "cancelTranslationTitle": "Cancel translation",
+ "deleteTranslationTitle": "Delete translation",
+ "previous": "Previous",
+ "next": "Next",
+ "pageInfo": "Page {{current}} of {{last}}",
+ "errors": {
+ "load": "Failed to load translations",
+ "create": "Failed to create translation",
+ "start": "Failed to start translation",
+ "estimate": "Failed to fetch translation estimate",
+ "cancel": "Failed to cancel translation",
+ "delete": "Failed to delete translation"
+ },
+ "confirm": {
+ "noPagesMessage": "All pages are already translated. Nothing will be billed.",
+ "ok": "OK",
+ "cancel": "Cancel",
+ "costLine": "Estimated cost: ~{{total}} (~{{avg}}/page on average). Canceling mid-run won't bill you for the full amount.",
+ "pageLabel": "1 page",
+ "pageLabelPlural": "{{count}} pages",
+ "messageReTranslate": "{{pageLabel}} will be re-translated. {{costLine}}",
+ "message": "{{pageLabel}} will be translated. {{costLine}}",
+ "overrideWarning": "All existing translations for this document and language will be replaced.",
+ "reTranslate": "Re-translate",
+ "translate": "Translate",
+ "titleOverride": "Override Re-translate All?",
+ "title": "Translate All?",
+ "deleteTitle": "Delete Translation",
+ "deleteMessage": "Are you sure you want to delete the translation \"{{name}}\"? This cannot be undone.",
+ "deleteName": "{{filename}} ({{language}})",
+ "documentFallback": "document",
+ "delete": "Delete"
+ }
+ },
+ "reader": {
+ "loadingDocument": "Loading document...",
+ "documentNotFound": "Document not found",
+ "backToTranslation": "Back to Translation",
+ "ofPages": "of {{count}}",
+ "syncScroll": "Sync Scroll",
+ "syncScrollTitle": "Sync scroll between panes",
+ "firstUntranslated": "First Untranslated",
+ "firstUntranslatedTitle": "Jump to first untranslated page",
+ "nextUntranslated": "Next Untranslated",
+ "nextUntranslatedTitle": "Jump to next untranslated page from here",
+ "wrongLanguage": "Wrong Language",
+ "prevWrongLangTitle": "Previous wrong-language page",
+ "nextWrongLangTitle": "Next wrong-language page",
+ "loadingPage": "Loading page...",
+ "beginningHidden": "Beginning of page hidden",
+ "beginningHiddenTitle": "The beginning of this page was included in the previous page's translation. It has been hidden here.",
+ "exitSplitMode": "Exit split mode",
+ "editSplits": "Edit splits",
+ "addSplits": "Add splits",
+ "noPageData": "No page data loaded.",
+ "pageIgnored": "This page is ignored",
+ "noExtractedText": "No extracted text",
+ "words": "{{count}} words",
+ "splitHint": "{{count}} chunk still too long — click in the red segment to add more splits",
+ "splitHintPlural": "{{count}} chunks still too long — click in the red segment to add more splits",
+ "removeSplitTitle": "Remove split",
+ "saving": "Saving...",
+ "translateWithSplits": "Translate with splits ({{count}} chunk)",
+ "translateWithSplitsPlural": "Translate with splits ({{count}} chunks)",
+ "needsSplitBanner": "This page has a paragraph that's too long to translate automatically. Use Add splits above to divide it into smaller chunks.",
+ "appendedFromNext": "Appended from next page",
+ "appendedFromNextTitle": "This text from the next page was appended here for translation continuity",
+ "edit": "Edit",
+ "clear": "Clear",
+ "manualTranslate": "Manual Translate",
+ "translating": "Translating...",
+ "aiTranslate": "AI Translate",
+ "aiTranslateNeedsSplitTitle": "Use Add splits to handle this page",
+ "cancel": "Cancel",
+ "save": "Save",
+ "translationUnavailable": "Translation unavailable",
+ "notOcrd": "This page hasn't been OCR'd yet.",
+ "openInExtraction": "Open in Text Extraction →",
+ "pageReferences": "This page references:",
+ "reAddRefHint": "to re-add a ref, type [^N] where N is the footnote number",
+ "editorPlaceholder": "Enter translation...",
+ "translatingWithAi": "Translating with AI...",
+ "translatingHint": "This may take a moment",
+ "wrongLangPage": "Translation returned in the source language instead of {{language}}.",
+ "wrongLangFootnote": "Footnote [^{{number}}] returned in the source language instead of {{language}}.",
+ "solve": "Solve",
+ "dismiss": "Dismiss",
+ "show": "Show",
+ "noTranslationYet": "No translation yet",
+ "noTranslationHint": "Use the buttons above to translate this page",
+ "originalLabel": "Original",
+ "fnNoTranslation": "No translation yet",
+ "retryTranslation": "Retry Translation",
+ "reTranslate": "Re-translate",
+ "translate": "Translate",
+ "errors": {
+ "loadDocument": "Failed to load document",
+ "loadPage": "Failed to load page data",
+ "allTranslated": "All available pages have been translated.",
+ "noMoreUntranslated": "No more untranslated pages after this one.",
+ "dismiss": "Failed to dismiss.",
+ "save": "Failed to save.",
+ "clear": "Failed to clear translation.",
+ "translationFailed": "Translation failed.",
+ "footnoteFailed": "Footnote translation failed.",
+ "saveSplits": "Failed to save splits."
+ },
+ "confirm": {
+ "cancel": "Cancel",
+ "keep": "Keep",
+ "retry": "Retry",
+ "retryTitle": "Retry with stronger instructions?",
+ "retryMessage": "Retry translating to {{language}} with a stronger prompt. Estimated cost: ~{{cost}}",
+ "reTranslatePageTitle": "Re-translate page?",
+ "translatePageTitle": "Translate page with AI?",
+ "translatePageReplace": "Existing translation will be replaced. ",
+ "translatePageMessage": "{{replace}}Estimated cost: ~{{cost}} (rough estimate based on character count)",
+ "translate": "Translate",
+ "wrongLangTitle": "Translation returned wrong language",
+ "wrongLangMessage": "Gemini returned the text in {{source}} instead of {{target}}. Retry with stronger instructions? Estimated cost: ~{{cost}} (rough estimate)",
+ "clearTitle": "Clear translation?",
+ "clearMessage": "The translation for this page will be permanently deleted.",
+ "clear": "Clear",
+ "splitsTooLongTitle": "Some chunks are still too long",
+ "splitsTooLongMessageOne": "{{count}} chunk is over 3000 words and may produce incomplete translations. Send anyway?",
+ "splitsTooLongMessageMany": "{{count}} chunks are over 3000 words and may produce incomplete translations. Send anyway?",
+ "sendAnyway": "Send anyway",
+ "goBack": "Go back",
+ "fnRetryTitle": "Retry footnote [^{{number}}] with stronger instructions?",
+ "fnRetryMessage": "Gemini returned this footnote in the source language instead of {{language}}. Retry with a stronger prompt? Estimated cost: ~{{cost}}",
+ "fnReTranslateTitle": "Re-translate footnote [^{{number}}]?",
+ "fnTranslateTitle": "Translate footnote [^{{number}}]?",
+ "fnReplace": "The existing footnote translation will be replaced. ",
+ "fnMessage": "{{replace}}Estimated cost: ~{{cost}} (rough estimate based on character count)",
+ "fnWrongLangTitle": "Footnote [^{{number}}] returned wrong language",
+ "fnWrongLangMessage": "Gemini returned this footnote in the source language instead of {{language}}. Retry with stronger instructions? Estimated cost: ~{{cost}}"
+ }
+ }
+}
diff --git a/wordkeep-client/public/assets/i18n/translation/ro.json b/wordkeep-client/public/assets/i18n/translation/ro.json
new file mode 100644
index 0000000..00ba090
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/translation/ro.json
@@ -0,0 +1,160 @@
+{
+ "list": {
+ "title": "Traducere",
+ "subtitle": "Traduceți documentele folosind AI.",
+ "newTranslation": "Traducere nouă",
+ "yourTranslations": "Traducerile tale",
+ "count": "{{count}} traducere",
+ "countPlural": "{{count}} traduceri",
+ "loading": "Se încarcă traducerile...",
+ "emptyTitle": "Încă nu există traduceri",
+ "emptyBody": "Apăsați „Traducere nouă” pentru a traduce un document",
+ "documentFallback": "Document #{{id}}",
+ "wrongLangPages": "{{count}} pagini cu limbă greșită",
+ "wrongLangFootnotes": "{{count}} note de subsol cu limbă greșită",
+ "page": "{{count}} pagină",
+ "pagePlural": "{{count}} pagini",
+ "translatedProgress": "{{done}}/{{total}} traduse ({{percent}}%)",
+ "overrideTitle": "Re-traduceți paginile deja traduse",
+ "override": "Suprascrie",
+ "translateAllTitle": "Traduceți toate paginile",
+ "translateAll": "Traduceți tot",
+ "cancelTranslationTitle": "Anulați traducerea",
+ "deleteTranslationTitle": "Ștergeți traducerea",
+ "previous": "Anterioara",
+ "next": "Următoarea",
+ "pageInfo": "Pagina {{current}} din {{last}}",
+ "errors": {
+ "load": "Încărcarea traducerilor a eșuat",
+ "create": "Crearea traducerii a eșuat",
+ "start": "Pornirea traducerii a eșuat",
+ "estimate": "Obținerea estimării traducerii a eșuat",
+ "cancel": "Anularea traducerii a eșuat",
+ "delete": "Ștergerea traducerii a eșuat"
+ },
+ "confirm": {
+ "noPagesMessage": "Toate paginile sunt deja traduse. Nu se va factura nimic.",
+ "ok": "OK",
+ "cancel": "Anulare",
+ "costLine": "Cost estimat: ~{{total}} (~{{avg}}/pagină în medie). Anularea la jumătate nu vă va factura suma integrală.",
+ "pageLabel": "1 pagină",
+ "pageLabelPlural": "{{count}} pagini",
+ "messageReTranslate": "{{pageLabel}} va fi re-tradusă. {{costLine}}",
+ "message": "{{pageLabel}} va fi tradusă. {{costLine}}",
+ "overrideWarning": "Toate traducerile existente pentru acest document și această limbă vor fi înlocuite.",
+ "reTranslate": "Re-traduceți",
+ "translate": "Traduceți",
+ "titleOverride": "Re-traduceți tot cu suprascriere?",
+ "title": "Traduceți tot?",
+ "deleteTitle": "Ștergeți traducerea",
+ "deleteMessage": "Sigur doriți să ștergeți traducerea „{{name}}”? Această acțiune nu poate fi anulată.",
+ "deleteName": "{{filename}} ({{language}})",
+ "documentFallback": "document",
+ "delete": "Ștergeți"
+ }
+ },
+ "reader": {
+ "loadingDocument": "Se încarcă documentul...",
+ "documentNotFound": "Documentul nu a fost găsit",
+ "backToTranslation": "Înapoi la Traducere",
+ "ofPages": "din {{count}}",
+ "syncScroll": "Sincronizare derulare",
+ "syncScrollTitle": "Sincronizați derularea între panouri",
+ "firstUntranslated": "Prima netradusă",
+ "firstUntranslatedTitle": "Salt la prima pagină netradusă",
+ "nextUntranslated": "Următoarea netradusă",
+ "nextUntranslatedTitle": "Salt la următoarea pagină netradusă de aici",
+ "wrongLanguage": "Limbă greșită",
+ "prevWrongLangTitle": "Pagina anterioară cu limbă greșită",
+ "nextWrongLangTitle": "Pagina următoare cu limbă greșită",
+ "loadingPage": "Se încarcă pagina...",
+ "beginningHidden": "Începutul paginii ascuns",
+ "beginningHiddenTitle": "Începutul acestei pagini a fost inclus în traducerea paginii anterioare. A fost ascuns aici.",
+ "exitSplitMode": "Ieșiți din modul divizare",
+ "editSplits": "Editați diviziunile",
+ "addSplits": "Adăugați diviziuni",
+ "noPageData": "Nicio dată de pagină încărcată.",
+ "pageIgnored": "Această pagină este ignorată",
+ "noExtractedText": "Niciun text extras",
+ "words": "{{count}} cuvinte",
+ "splitHint": "{{count}} fragment încă prea lung — apăsați în segmentul roșu pentru a adăuga mai multe diviziuni",
+ "splitHintPlural": "{{count}} fragmente încă prea lungi — apăsați în segmentul roșu pentru a adăuga mai multe diviziuni",
+ "removeSplitTitle": "Eliminați diviziunea",
+ "saving": "Se salvează...",
+ "translateWithSplits": "Traduceți cu diviziuni ({{count}} fragment)",
+ "translateWithSplitsPlural": "Traduceți cu diviziuni ({{count}} fragmente)",
+ "needsSplitBanner": "Această pagină are un paragraf prea lung pentru a fi tradus automat. Folosiți Adăugați diviziuni mai sus pentru a-l împărți în fragmente mai mici.",
+ "appendedFromNext": "Adăugat din pagina următoare",
+ "appendedFromNextTitle": "Acest text din pagina următoare a fost adăugat aici pentru continuitatea traducerii",
+ "edit": "Editați",
+ "clear": "Ștergeți",
+ "manualTranslate": "Traducere manuală",
+ "translating": "Se traduce...",
+ "aiTranslate": "Traducere AI",
+ "aiTranslateNeedsSplitTitle": "Folosiți Adăugați diviziuni pentru această pagină",
+ "cancel": "Anulare",
+ "save": "Salvați",
+ "translationUnavailable": "Traducere indisponibilă",
+ "notOcrd": "Această pagină nu a fost încă procesată OCR.",
+ "openInExtraction": "Deschideți în Extragere text →",
+ "pageReferences": "Această pagină face referire la:",
+ "reAddRefHint": "pentru a re-adăuga o referință, tastați [^N] unde N este numărul notei de subsol",
+ "editorPlaceholder": "Introduceți traducerea...",
+ "translatingWithAi": "Se traduce cu AI...",
+ "translatingHint": "Acest lucru poate dura un moment",
+ "wrongLangPage": "Traducerea a fost returnată în limba sursă în loc de {{language}}.",
+ "wrongLangFootnote": "Nota de subsol [^{{number}}] a fost returnată în limba sursă în loc de {{language}}.",
+ "solve": "Rezolvați",
+ "dismiss": "Respingeți",
+ "show": "Arătați",
+ "noTranslationYet": "Încă nicio traducere",
+ "noTranslationHint": "Folosiți butoanele de mai sus pentru a traduce această pagină",
+ "originalLabel": "Original",
+ "fnNoTranslation": "Încă nicio traducere",
+ "retryTranslation": "Reîncercați traducerea",
+ "reTranslate": "Re-traduceți",
+ "translate": "Traduceți",
+ "errors": {
+ "loadDocument": "Încărcarea documentului a eșuat",
+ "loadPage": "Încărcarea datelor paginii a eșuat",
+ "allTranslated": "Toate paginile disponibile au fost traduse.",
+ "noMoreUntranslated": "Nu mai există pagini netraduse după aceasta.",
+ "dismiss": "Respingerea a eșuat.",
+ "save": "Salvarea a eșuat.",
+ "clear": "Ștergerea traducerii a eșuat.",
+ "translationFailed": "Traducerea a eșuat.",
+ "footnoteFailed": "Traducerea notei de subsol a eșuat.",
+ "saveSplits": "Salvarea diviziunilor a eșuat."
+ },
+ "confirm": {
+ "cancel": "Anulare",
+ "keep": "Păstrați",
+ "retry": "Reîncercați",
+ "retryTitle": "Reîncercați cu instrucțiuni mai puternice?",
+ "retryMessage": "Reîncercați traducerea în {{language}} cu un prompt mai puternic. Cost estimat: ~{{cost}}",
+ "reTranslatePageTitle": "Re-traduceți pagina?",
+ "translatePageTitle": "Traduceți pagina cu AI?",
+ "translatePageReplace": "Traducerea existentă va fi înlocuită. ",
+ "translatePageMessage": "{{replace}}Cost estimat: ~{{cost}} (estimare aproximativă pe baza numărului de caractere)",
+ "translate": "Traduceți",
+ "wrongLangTitle": "Traducerea a returnat o limbă greșită",
+ "wrongLangMessage": "Gemini a returnat textul în {{source}} în loc de {{target}}. Reîncercați cu instrucțiuni mai puternice? Cost estimat: ~{{cost}} (estimare aproximativă)",
+ "clearTitle": "Ștergeți traducerea?",
+ "clearMessage": "Traducerea pentru această pagină va fi ștearsă definitiv.",
+ "clear": "Ștergeți",
+ "splitsTooLongTitle": "Unele fragmente sunt încă prea lungi",
+ "splitsTooLongMessageOne": "{{count}} fragment are peste 3000 de cuvinte și poate produce traduceri incomplete. Trimiteți oricum?",
+ "splitsTooLongMessageMany": "{{count}} fragmente au peste 3000 de cuvinte și pot produce traduceri incomplete. Trimiteți oricum?",
+ "sendAnyway": "Trimiteți oricum",
+ "goBack": "Înapoi",
+ "fnRetryTitle": "Reîncercați nota de subsol [^{{number}}] cu instrucțiuni mai puternice?",
+ "fnRetryMessage": "Gemini a returnat această notă de subsol în limba sursă în loc de {{language}}. Reîncercați cu un prompt mai puternic? Cost estimat: ~{{cost}}",
+ "fnReTranslateTitle": "Re-traduceți nota de subsol [^{{number}}]?",
+ "fnTranslateTitle": "Traduceți nota de subsol [^{{number}}]?",
+ "fnReplace": "Traducerea existentă a notei de subsol va fi înlocuită. ",
+ "fnMessage": "{{replace}}Cost estimat: ~{{cost}} (estimare aproximativă pe baza numărului de caractere)",
+ "fnWrongLangTitle": "Nota de subsol [^{{number}}] a returnat o limbă greșită",
+ "fnWrongLangMessage": "Gemini a returnat această notă de subsol în limba sursă în loc de {{language}}. Reîncercați cu instrucțiuni mai puternice? Cost estimat: ~{{cost}}"
+ }
+ }
+}
diff --git a/wordkeep-client/scripts/check-i18n.mjs b/wordkeep-client/scripts/check-i18n.mjs
new file mode 100644
index 0000000..74cf4af
--- /dev/null
+++ b/wordkeep-client/scripts/check-i18n.mjs
@@ -0,0 +1,123 @@
+/**
+ * CI guard against hardcoded user-facing strings in component templates.
+ *
+ * Heuristically scans every component .html under src/app for visible text
+ * nodes that are NOT wrapped in a Transloco binding. It deliberately ignores:
+ * - pure interpolations ({{ ... }}), which are dynamic
+ * - contents and HTML comments
+ * - elements marked lang="la" (the charter's intentional Latin)
+ * - punctuation / entities / numbers only
+ *
+ * Templates that still contain un-extracted strings are listed in
+ * scripts/i18n-allowlist.txt (one repo-relative path per line). A file NOT on
+ * the allowlist must be clean — otherwise the check fails. As templates are
+ * migrated to Transloco, remove them from the allowlist; new components are
+ * enforced by default, so fresh hardcoded strings fail CI.
+ *
+ * Run from wordkeep-client/: node scripts/check-i18n.mjs
+ */
+
+import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
+import { fileURLToPath } from 'url';
+import { dirname, join, relative, sep } from 'path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const clientRoot = join(__dirname, '..');
+const appDir = join(clientRoot, 'src/app');
+const allowlistPath = join(__dirname, 'i18n-allowlist.txt');
+
+// ── allowlist ────────────────────────────────────────────────────────
+const allowlist = new Set(
+ existsSync(allowlistPath)
+ ? readFileSync(allowlistPath, 'utf8')
+ .split('\n')
+ .map(l => l.trim())
+ .filter(l => l && !l.startsWith('#'))
+ .map(l => l.split('/').join(sep))
+ : []
+);
+
+// ── find component templates ─────────────────────────────────────────
+function walk(dir, out = []) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) walk(full, out);
+ else if (entry.name.endsWith('.html')) out.push(full);
+ }
+ return out;
+}
+
+// ── heuristic scan of one template ───────────────────────────────────
+function scan(html) {
+ let s = html;
+ // Drop comments, blocks (icons/seals),