')`, 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/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/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/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/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/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/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..24e3778
--- /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": "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..276b305
--- /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": "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..e520a16
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/charter/en.json
@@ -0,0 +1,44 @@
+{
+ "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": {
+ "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"
+ },
+ "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"
+ },
+ "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"
+ },
+ "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..355f02a
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/charter/ro.json
@@ -0,0 +1,44 @@
+{
+ "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": {
+ "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"
+ },
+ "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"
+ },
+ "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"
+ },
+ "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/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..7bd69fb
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/extraction/en.json
@@ -0,0 +1,178 @@
+{
+ "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",
+ "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..78de651
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/extraction/ro.json
@@ -0,0 +1,178 @@
+{
+ "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",
+ "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..b9012e2
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/pageLinking/en.json
@@ -0,0 +1,50 @@
+{
+ "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": "Page Linking",
+ "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..5cb9900
--- /dev/null
+++ b/wordkeep-client/public/assets/i18n/pageLinking/ro.json
@@ -0,0 +1,50 @@
+{
+ "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": "Legarea paginilor",
+ "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),