Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
7167eb9
Add acquisition booklet package + mirror new memory
SteadfastKnight Jun 25, 2026
30d8c0b
Remove orphaned personalisation screenshot
SteadfastKnight Jun 25, 2026
b7e9128
Duplex booklet layout: mirrored gutter, blank verso, folio renumber
SteadfastKnight Jun 29, 2026
05d3eae
Add paper/stock & presentation guidance to binding doc
SteadfastKnight Jun 29, 2026
1d9d921
Add audit-verified 'Road ahead' roadmap page + Jira backlog fetch script
SteadfastKnight Jun 29, 2026
6740e94
Add Romanian binding/spire instructions + cross-link
SteadfastKnight Jun 29, 2026
38e910f
Roadmap: add Word export + illustration extraction, fix export bullet
SteadfastKnight Jun 29, 2026
8a6b764
Generalize Jira tooling into a reusable, data-driven toolkit
SteadfastKnight Jun 29, 2026
b8375c5
Updated the roadmap section to leave out the future welcome email
SteadfastKnight Jun 29, 2026
867d713
[WKP-47] Add guest demo for unauthenticated users
SteadfastKnight Jun 30, 2026
e63795d
Booklet: add market-validation + cost/income projection pages
SteadfastKnight Jun 30, 2026
3cdf75a
Fix stale Gemini translation pricing constants
SteadfastKnight Jun 30, 2026
ddbe864
Merge branch 'feature/booklet' into feature/WKP-47-Demo-for-unauthent…
SteadfastKnight Jun 30, 2026
69f9dd4
[WKP-47] Demo reads live translation pricing
SteadfastKnight Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,6 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1

# Translation Configuration (Google Gemini)
# Get a free API key at https://aistudio.google.com — free tier: 10 RPM, 250 req/day
# Paid: ~$0.42/300-page book (gemini-2.5-flash) or ~$0.08 (gemini-2.5-flash-lite)
# Paid: ~$0.85/300-page book (gemini-2.5-flash) or ~$0.15 (gemini-2.5-flash-lite)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
2 changes: 1 addition & 1 deletion .env.herd.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,6 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1

# Translation Configuration (Google Gemini)
# Get a free API key at https://aistudio.google.com — free tier: 10 RPM, 250 req/day
# Paid: ~$0.42/300-page book (gemini-2.5-flash) or ~$0.08 (gemini-2.5-flash-lite)
# Paid: ~$0.85/300-page book (gemini-2.5-flash) or ~$0.15 (gemini-2.5-flash-lite)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
2 changes: 1 addition & 1 deletion .env.sail.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,6 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1

# Translation Configuration (Google Gemini)
# Get a free API key at https://aistudio.google.com — free tier: 10 RPM, 250 req/day
# Paid: ~$0.42/300-page book (gemini-2.5-flash) or ~$0.08 (gemini-2.5-flash-lite)
# Paid: ~$0.85/300-page book (gemini-2.5-flash) or ~$0.15 (gemini-2.5-flash-lite)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ A Laravel 12 API server + Angular 21 client for processing PDF documents using A
- **Latin↔English Hover Swap** — On desktop, hover or keyboard-focus any Latin block (legend, opening, article titles/bodies, `manu propria` signatures, closing) and the Latin fades out while English fades in; un-hover/un-focus restores. On touch, tap toggles via `aria-expanded`
- **Sign-in / Sign-up CTAs** — Footer offers "Subscribe to the charter" (`/register`) and "Resume the office" (`/login`)

### Guest Demo
- **Guided tour at `/demo`** — A "Witness the craft" CTA (and per-Article Latin deep-links) take a logged-out visitor through the full product flow without signing up: **extraction → page linking → book view → translation → translated book view**, framed in charter-styled chrome with five wax-seal step pips
- **Real pipeline output, not mockups** — The tour runs on a 3-page public-domain *Vanity Fair* extract carried through WordKeep's own pipeline (LM Studio OCR + Gemini translation). Two layers per page in `wordkeep-client/src/app/demo/demo.fixture.ts`: the **raw** OCR (with authentic slips) in the extraction stage, and the **cleaned** source + its captured Romanian everywhere downstream — the "best result" a real editor would produce
- **Canned and local — no AI, no backend** — Each reader's data service is swapped for an in-memory override via route-level DI (`DemoDocumentService` / `DemoTranslationService`), so the tour is fully interactive (extract, edit, link, translate, view footnotes) yet makes no model calls and **resets on exit**. The only network call is the public, read-only `GET /api/translation/pricing`, so the cost dialog always shows live prices (never a hand-maintained copy); a guard E2E spec asserts no other `/api` traffic
- **In-demo personalisation** — Switch light/dark and colour palette live; the six layout variants are intentionally withheld as a post-signup discovery. Footnotes are view-only; the real cost/clear/re-OCR confirmation dialogs still appear

### Dashboard & Recent Activity
- **Recent Activity Feed** — Dashboard shows up to 10 most recent `(document, work-type)` entries, ordered by latest activity. Each card displays the filename, author (when present), a colored work-type badge (**OCR**, **Page Linking**, **Book View**, **Translation — {Language}**), the last page worked + total pages, a progress bar, and a relative timestamp (`2h ago`, `3d ago`, or `DD/MM/YYYY` for entries older than a week)
- **One Entry Per Work Type** — A single book worked on for both OCR and Page Linking surfaces as two separate entries
Expand Down Expand Up @@ -99,6 +105,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 @@ -343,6 +357,8 @@ npm test -- --coverage

For Sail: requires `sail up -d` to be running. For Herd: requires the API (`php artisan serve`) and the Angular dev server (`npm start`) to be running.

> The guest demo spec (`e2e/demo.spec.ts`) is the exception — it's an unauthenticated flow that runs against the Angular dev server alone (no login/seeding), and asserts the tour makes no `/api` calls beyond the one public price-list GET (`/api/translation/pricing`).

**Important:** The API `.env` must have `CACHE_STORE=array` before running E2E tests.
Without it, login rate limiting will throttle the test suite after the 5th test (429
errors). The JetBrains run configs (`.run/`) handle this automatically — they patch
Expand Down Expand Up @@ -457,6 +473,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
Loading
Loading