Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
74cd5e0
[WKP-36] Add Romanian localization foundation
SteadfastKnight Jun 2, 2026
641f9c6
[WKP-36] Localize auth + profile pages (Phase 6)
SteadfastKnight Jun 2, 2026
a771dc2
[WKP-36] Localize sort-dropdown + rename-dialog (Phase 6)
SteadfastKnight Jun 2, 2026
2ea8000
[WKP-36] Localize recent-activity widget (Phase 6)
SteadfastKnight Jun 2, 2026
34c58b6
[WKP-36] Localize remaining feature pages, dialogs, shell + home vari…
SteadfastKnight Jun 2, 2026
1ad294a
[WKP-36] Localize genres + language names, fix Romanian plurals (ICU)
SteadfastKnight Jun 2, 2026
5f3e828
[WKP-36] Document i18n plural (ICU) + global language-map conventions
SteadfastKnight Jun 2, 2026
e6a5b75
[WKP-36] Localize transactional emails, API messages, validation fiel…
SteadfastKnight Jun 5, 2026
953bdd1
[WKP-36] Localize confirmation dialogs + fix dialog button styling
SteadfastKnight Jun 5, 2026
19b95ec
[WKP-36] Localize IGNORED page overlay
SteadfastKnight Jun 9, 2026
3e2f572
[WKP-36] Localize timestamps to active locale
SteadfastKnight Jun 9, 2026
4e1b169
[WKP-36] Console layout: ANSI-dot badges + localize status bar/nav
SteadfastKnight Jun 9, 2026
b3af6ea
[WKP-36] Localize doc status + personalisation subtitle; fix dictiona…
SteadfastKnight Jun 9, 2026
eb16ad4
[WKP-36] Add i18n:translate npm script
SteadfastKnight Jun 9, 2026
a4d6ce9
[WKP-36] Bump Playwright to 1.60.0 to fix install hang
SteadfastKnight Jun 9, 2026
e761385
[WKP-36] Fix flaky BulkTranslate page-linking test
SteadfastKnight Jun 9, 2026
ea3c973
[WKP-36] Make translate-i18n.mjs target any language via CLI arg
SteadfastKnight Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/server-and-client-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check for hardcoded i18n strings
run: npm run check:i18n

- name: Run tests
run: npm test

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ A Laravel 12 API server + Angular 21 client for processing PDF documents using A
- **Personalisation Page** — `/profile/personalisation`, a third sub-page alongside Info and Security. Hosts the layout picker (6 cards), palette picker (6 swatches), and theme-mode segmented control. Selections take effect instantly with no reload.
- **Console-only `/` Command Palette** — when on the Console layout, pressing `/` opens a centred terminal-style overlay for keyboard-driven navigation (autocomplete on routes, ↑↓ to move, Enter to navigate, Esc to close). The listener is scoped so typing `/` inside an `<input>`, `<textarea>`, or `[contenteditable]` doesn't hijack — same pattern as GitHub / Linear / Notion.

### Localization
- **English & Romanian** — The entire UI ships in English (default) and Romanian. Users switch language from two medieval swallowtail flags on the guest landing page, or from the segmented control in the profile dropdown and on `/profile/personalisation`.
- **Cross-Device Persistence** — The chosen locale is stored in `localStorage` (`wk:locale`) and on the server (`user_preferences.locale`). A language picked pre-login is adopted on first login when the account has no saved locale yet; otherwise the server value wins.
- **Localized Backend** — API validation errors, authentication messages, and all six transactional emails arrive in the user's language. The frontend sends `Accept-Language`; a `SetLocale` middleware applies it per request, and queued mail localizes per-recipient via `HasLocalePreference`.
- **Regional Formats** — Dates and numbers follow regional convention per locale: Romanian renders `28.05.2026` and `1.234,56`; English renders `28/05/2026` and `1,234.56`.
- **Locale-Aware Charter** — The landing charter keeps its Latin layer untouched; its hover/tap translation layer swaps between English and Romanian with the active locale.
- **Built on Transloco** — Runtime translations with per-feature lazy scopes (`wordkeep-client/public/assets/i18n/`). A CI guard (`npm run check:i18n`) blocks new hardcoded user-facing strings.

### Page Linking
- **Consecutive Page Pairs** — View two adjacent pages side-by-side to determine how text flows between them
- **New Row / Continue Row** — Tag each boundary as a new paragraph row or a continuation of the previous page
Expand Down Expand Up @@ -457,6 +465,25 @@ These all default to behavior-preserving values — set them only if you want to
| `BULK_OCR_ENABLED` | `true` | `ProcessBulkOcr` job exits early — keeps the document at `pending` so a cancel call can win the race during testing. |
| `QUEUE_WORKER_MANAGED` | _(unset)_ | When set truthy, the auto-spawn `EnsureQueueWorkerIsRunning` listener short-circuits — set this if a separate process supervisor (e.g. supervisord) is already running the worker, to avoid duplicate workers. |

## Localization

The UI and backend messages ship in English (`en`, default) and Romanian (`ro`).

- **Frontend translations** live in `wordkeep-client/public/assets/i18n/` — a global `en.json`/`ro.json` plus one folder per lazy feature scope (`charter/`, `profile/`, …). They are loaded at runtime by Transloco; scopes are attached per route in `src/app/app.routes.ts`.
- **Backend translations** live in `lang/{en,ro}/` (`auth`, `validation`, `passwords`, `pagination`, and `emails`). Locale is resolved per request from `Accept-Language` by `app/Http/Middleware/SetLocale.php`; queued mail uses the recipient's `User::preferredLocale()`.
- **No hardcoded strings** — `npm run check:i18n` (run in CI) flags visible template text that isn't wrapped in a Transloco binding. Templates pending migration are listed in `wordkeep-client/scripts/i18n-allowlist.txt`.

### Adding a new locale

1. Add the locale code to `availableLangs` in `wordkeep-client/src/app/app.config.ts`, and `registerLocaleData` for its Angular date/number formats. Add it to the `LOCALE_VALUES`/`LOCALE_IDS` in `src/app/services/user-preferences.service.ts` and the `in:en,ro` rule in `app/Http/Requests/UpdateUserPreferencesRequest.php` + the `SetLocale` supported list.
2. Seed translation files from English with Gemini, passing the target locale code (e.g. `fr`):
```bash
GEMINI_API_KEY=… node scripts/translate-i18n.mjs <lang> # creates <lang>.json siblings
# or: npm run i18n:translate -- <lang>
```
`<lang>` is required and must be one of the codes in the script's `LANG_NAMES` map (run with no args to list them). Add `--dry-run` to preview without writing. The script preserves placeholders, adapts ICU plural categories to the target language's CLDR rules, and merges on re-run (reviewed strings are never overwritten). Then mirror `lang/en/` to `lang/<locale>/`.
3. Review and fix the generated copy, then add the locale to the language switchers (profile dropdown, personalisation page, and — if desired — the landing flags).

## API Endpoints

Interactive API documentation is available at `/docs/api` (served by [Scramble](https://scramble.dedoc.co)).
Expand Down
36 changes: 18 additions & 18 deletions app/Http/Controllers/Api/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ public function register(Request $request): JsonResponse
$token = $user->generateVerificationToken();
$verificationUrl = config('app.frontend_url') . '/verify-email?token=' . $token;

Mail::to($user)->queue(new VerifyEmail($user, $verificationUrl));
Mail::to($user)->locale(app()->getLocale())->queue(new VerifyEmail($user, $verificationUrl));

return response()->json([
'message' => 'Registration successful. Please check your email to verify your account.',
'message' => __('messages.auth.registered'),
'requires_verification' => true,
], 201);
}
Expand All @@ -67,14 +67,14 @@ public function login(Request $request): JsonResponse

if (!$user || !Hash::check($validated['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
'email' => [__('auth.failed')],
]);
}

if (!$user->isVerified()) {
return response()->json([
'error' => 'email_not_verified',
'message' => 'Please verify your email before logging in. Check your inbox for the verification link.',
'message' => __('messages.auth.email_not_verified'),
], 403);
}

Expand Down Expand Up @@ -115,12 +115,12 @@ public function loginWithTwoFactor(Request $request): JsonResponse
$user = User::where('two_factor_challenge_token', $validated['challenge_token'])->first();

if (!$user) {
return response()->json(['message' => 'Invalid or expired challenge.'], 400);
return response()->json(['message' => __('messages.auth.challenge_invalid')], 400);
}

if ($user->two_factor_challenge_expires_at->isPast()) {
$user->update(['two_factor_challenge_token' => null, 'two_factor_challenge_expires_at' => null]);
return response()->json(['message' => 'Challenge expired. Please log in again.'], 400);
return response()->json(['message' => __('messages.auth.challenge_expired')], 400);
}

$google2fa = new \PragmaRX\Google2FA\Google2FA();
Expand All @@ -129,7 +129,7 @@ public function loginWithTwoFactor(Request $request): JsonResponse

if (!$validTotp && !$validRecovery) {
return response()->json([
'errors' => ['code' => ['Invalid code.']],
'errors' => ['code' => [__('messages.auth.code_invalid')]],
], 422);
}

Expand Down Expand Up @@ -160,7 +160,7 @@ public function verify(Request $request): JsonResponse
if (!$user) {
return response()->json([
'error' => 'invalid_token',
'message' => 'Invalid or expired verification token.',
'message' => __('messages.auth.verification_token_invalid'),
], 400);
}

Expand All @@ -169,7 +169,7 @@ public function verify(Request $request): JsonResponse
$token = $user->createToken('auth-token')->plainTextToken;

return response()->json([
'message' => 'Email verified successfully.',
'message' => __('messages.auth.email_verified'),
'user' => $this->userPayload($user),
'token' => $token,
]);
Expand All @@ -195,11 +195,11 @@ public function resendVerification(Request $request): JsonResponse
if ($user) {
$token = $user->generateVerificationToken();
$verificationUrl = config('app.frontend_url') . '/verify-email?token=' . $token;
Mail::to($user)->queue(new VerifyEmail($user, $verificationUrl));
Mail::to($user)->locale(app()->getLocale())->queue(new VerifyEmail($user, $verificationUrl));
}

return response()->json([
'message' => 'If an unverified account exists, a verification email has been sent.',
'message' => __('messages.auth.verification_resent'),
]);
}

Expand Down Expand Up @@ -229,11 +229,11 @@ public function forgotPassword(Request $request): JsonResponse
]);

$resetUrl = config('app.frontend_url') . '/reset-password?token=' . $token . '&email=' . urlencode($user->email);
Mail::to($user)->queue(new PasswordReset($user, $resetUrl));
Mail::to($user)->locale(app()->getLocale())->queue(new PasswordReset($user, $resetUrl));
}

return response()->json([
'message' => 'If an account exists, a password reset link has been sent.',
'message' => __('messages.auth.reset_link_sent'),
]);
}

Expand All @@ -259,14 +259,14 @@ public function resetPassword(Request $request): JsonResponse
if (!$record) {
return response()->json([
'error' => 'invalid_token',
'message' => 'Invalid or expired reset token.',
'message' => __('messages.auth.reset_token_invalid'),
], 400);
}

if (!Hash::check($validated['token'], $record->token)) {
return response()->json([
'error' => 'invalid_token',
'message' => 'Invalid or expired reset token.',
'message' => __('messages.auth.reset_token_invalid'),
], 400);
}

Expand All @@ -275,7 +275,7 @@ public function resetPassword(Request $request): JsonResponse
DB::table('password_reset_tokens')->where('email', $validated['email'])->delete();
return response()->json([
'error' => 'token_expired',
'message' => 'Reset link has expired. Please request a new one.',
'message' => __('messages.auth.reset_link_expired'),
], 400);
}

Expand All @@ -287,7 +287,7 @@ public function resetPassword(Request $request): JsonResponse
$token = $user->createToken('auth-token')->plainTextToken;

return response()->json([
'message' => 'Password reset successful.',
'message' => __('messages.auth.password_reset'),
'user' => $this->userPayload($user),
'token' => $token,
]);
Expand All @@ -307,7 +307,7 @@ public function logout(Request $request): JsonResponse
}

return response()->json([
'message' => 'Successfully logged out',
'message' => __('messages.auth.logged_out'),
]);
}

Expand Down
10 changes: 5 additions & 5 deletions app/Http/Controllers/Api/BulkOcrController.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ public function start(Document $document): JsonResponse
if (!$document->isReady()) {
return response()->json([
'error' => 'document_not_ready',
'message' => 'Document is not ready for OCR processing.',
'message' => __('messages.bulk_ocr.not_ready'),
], 400);
}

if ($document->bulk_ocr_status === 'running') {
return response()->json([
'error' => 'already_running',
'message' => 'Bulk OCR is already running for this document.',
'message' => __('messages.bulk_ocr.already_running'),
], 400);
}

Expand All @@ -54,7 +54,7 @@ public function start(Document $document): JsonResponse
'bulk_ocr_completed' => 0,
'page_count' => $document->page_count,
],
'message' => 'Bulk OCR job queued successfully.',
'message' => __('messages.bulk_ocr.queued'),
]);
}

Expand All @@ -72,7 +72,7 @@ public function cancel(Document $document): JsonResponse
if (!in_array($document->bulk_ocr_status, ['pending', 'running'])) {
return response()->json([
'error' => 'not_running',
'message' => 'No bulk OCR job is running for this document.',
'message' => __('messages.bulk_ocr.no_running_job'),
], 400);
}

Expand All @@ -85,7 +85,7 @@ public function cancel(Document $document): JsonResponse
'document_id' => $document->id,
'bulk_ocr_status' => 'cancelled',
],
'message' => 'Bulk OCR job cancelled.',
'message' => __('messages.bulk_ocr.cancelled'),
]);
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/DocumentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public function retry(Document $document): DocumentResource|JsonResponse
}

if ($document->status !== 'failed') {
return response()->json(['error' => 'Only failed documents can be retried'], 422);
return response()->json(['error' => __('messages.documents.retry_only_failed')], 422);
}

$document = $this->pdfService->retry($document);
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/FootnoteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public function unlink(Document $document, DocumentFootnote $footnote): JsonResp
}

if (!$footnote->page_number) {
return response()->json(['error' => 'Footnote is not linked to a page'], 422);
return response()->json(['error' => __('messages.footnotes.not_linked')], 422);
}

$this->removeRefsFromPageTexts($document, $footnote->number);
Expand Down
8 changes: 4 additions & 4 deletions app/Http/Controllers/Api/OcrController.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public function updatePage(Document $document, int $page): JsonResponse
if (!$documentPage) {
return response()->json([
'error' => 'page_not_found',
'message' => "Page {$page} not found.",
'message' => __('messages.pages.not_found', ['page' => $page]),
], 404);
}

Expand Down Expand Up @@ -185,7 +185,7 @@ public function updatePage(Document $document, int $page): JsonResponse
if (!$footnote || $footnote->page_number !== $page) {
return response()->json([
'error' => 'invalid_footnote_ref',
'message' => 'Use the insert button to add footnote references.',
'message' => __('messages.ocr.footnote_use_insert'),
], 422);
}
}
Expand All @@ -196,7 +196,7 @@ public function updatePage(Document $document, int $page): JsonResponse
if (!empty($removedNumbers)) {
return response()->json([
'error' => 'invalid_footnote_ref',
'message' => 'Use the unlink button to remove footnote references.',
'message' => __('messages.ocr.footnote_use_unlink'),
], 422);
}
}
Expand Down Expand Up @@ -302,7 +302,7 @@ public function setIgnored(Document $document, int $page): JsonResponse
$pageModel = $document->pages()->where('page_number', $page)->first();

if (!$pageModel) {
return response()->json(['error' => 'Page not found'], 404);
return response()->json(['error' => __('messages.pages.not_found_plain')], 404);
}

$ignore = $validated['ignored'];
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Api/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function image(Document $document, int $page): BinaryFileResponse|Respons
} catch (\Exception $e) {
return response()->json([
'error' => 'image_not_found',
'message' => 'Page image could not be generated.',
'message' => __('messages.pages.image_unavailable'),
], 500);
}

Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/Api/PageLinkingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public function show(Document $document, int $page): JsonResponse
}

if ($page < 1 || $page >= $document->page_count) {
return response()->json(['error' => 'Invalid page'], 422);
return response()->json(['error' => __('messages.page_linking.invalid_page')], 422);
}

$pageA = DocumentPage::firstOrCreate(['document_id' => $document->id, 'page_number' => $page]);
Expand Down Expand Up @@ -102,11 +102,11 @@ public function saveLink(Document $document, int $page): JsonResponse
$pageB = DocumentPage::firstOrCreate(['document_id' => $document->id, 'page_number' => $page + 1]);

if ($pageA->link_to_next_page === DocumentPage::LINK_DOCUMENT_END) {
return response()->json(['error' => 'Invalid page'], 422);
return response()->json(['error' => __('messages.page_linking.invalid_page')], 422);
}

if ($pageA->ignored || ($pageB && $pageB->ignored)) {
return response()->json(['error' => 'Cannot set link for ignored page'], 422);
return response()->json(['error' => __('messages.page_linking.ignored_page')], 422);
}

DB::transaction(function () use ($pageA, $pageB, $decision) {
Expand Down
Loading
Loading