From 74cd5e0decac359aaaafc54850e3dca764d6c998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 2 Jun 2026 14:46:13 +0300 Subject: [PATCH 01/17] [WKP-36] Add Romanian localization foundation Frontend: Transloco runtime i18n with lazy per-route scopes; locale signal on UserPreferencesService persisted to localStorage + /api/preferences; Accept-Language interceptor; locale->Transloco bridge in app.config. Language switchers (profile dropdown + personalisation). Charter landing English layer locale-aware + EN/RO swallowtail flags. Locale-aware numeric dates. Backend: nullable user_preferences.locale + migration; SetLocale middleware; lang/ro for auth/validation/passwords/pagination + emails; 6 mailables and blades localized; User implements HasLocalePreference. Tooling: scripts/translate-i18n.mjs (Gemini), check-i18n.mjs CI guard + allowlist. Tests: PHPUnit LocalizationTest, preferences locale cases, interceptor/prefs unit specs, landing + locale-switch e2e. Phase 6 (per-template string extraction) deferred; pending templates allowlisted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/server-and-client-tests.yml | 3 + README.md | 26 +++ app/Http/Controllers/Api/AuthController.php | 2 +- app/Http/Middleware/SetLocale.php | 41 ++++ app/Http/Requests/StoreDocumentRequest.php | 10 +- .../Requests/UpdateUserPreferencesRequest.php | 1 + app/Http/Resources/UserPreferenceResource.php | 1 + app/Mail/PasswordChanged.php | 2 +- app/Mail/PasswordReset.php | 2 +- app/Mail/TwoFactorDisabled.php | 2 +- app/Mail/TwoFactorEnabled.php | 2 +- app/Mail/VerifyEmail.php | 2 +- app/Mail/VerifyEmailChange.php | 2 +- app/Models/User.php | 13 +- app/Models/UserPreference.php | 2 +- bootstrap/app.php | 4 + ...5_add_locale_to_user_preferences_table.php | 30 +++ docs/claude-code-memories/MEMORY.md | 4 +- .../frontend-tests-use-npm-test.md | 12 + .../i18n-transloco-architecture.md | 18 ++ .../numeric-date-format.md | 10 +- lang/en/auth.php | 20 ++ lang/en/emails.php | 59 +++++ lang/en/pagination.php | 19 ++ lang/en/passwords.php | 22 ++ lang/en/validation.php | 202 ++++++++++++++++ lang/ro/auth.php | 15 ++ lang/ro/emails.php | 59 +++++ lang/ro/pagination.php | 14 ++ lang/ro/passwords.php | 17 ++ lang/ro/validation.php | 175 ++++++++++++++ .../views/emails/password-changed.blade.php | 8 +- .../views/emails/password-reset.blade.php | 12 +- .../emails/two-factor-disabled.blade.php | 8 +- .../views/emails/two-factor-enabled.blade.php | 10 +- .../emails/verify-email-change.blade.php | 12 +- resources/views/emails/verify-email.blade.php | 12 +- scripts/translate-i18n.mjs | 216 ++++++++++++++++++ tests/Feature/LocalizationTest.php | 100 ++++++++ .../Feature/UserPreferencesControllerTest.php | 36 +++ wordkeep-client/e2e/landing.spec.ts | 20 ++ wordkeep-client/e2e/locale-switch.spec.ts | 34 +++ wordkeep-client/package-lock.json | 195 +++++++++++++++- wordkeep-client/package.json | 4 +- .../public/assets/i18n/charter/en.json | 44 ++++ .../public/assets/i18n/charter/ro.json | 44 ++++ wordkeep-client/public/assets/i18n/en.json | 25 ++ .../public/assets/i18n/profile/en.json | 12 + .../public/assets/i18n/profile/ro.json | 12 + wordkeep-client/public/assets/i18n/ro.json | 25 ++ wordkeep-client/scripts/check-i18n.mjs | 113 +++++++++ wordkeep-client/scripts/i18n-allowlist.txt | 55 +++++ wordkeep-client/src/app/app.config.ts | 42 +++- wordkeep-client/src/app/app.routes.ts | 5 +- .../recent-activity/recent-activity.spec.ts | 2 + .../recent-activity/recent-activity.ts | 4 +- .../user-menu-dropdown.html | 27 ++- .../user-menu-dropdown.scss | 50 ++++ .../user-menu-dropdown.spec.ts | 7 +- .../user-menu-dropdown/user-menu-dropdown.ts | 9 +- .../app/interceptors/auth.interceptor.spec.ts | 26 ++- .../src/app/interceptors/auth.interceptor.ts | 22 +- .../src/app/pages/landing/landing.html | 91 ++++---- .../src/app/pages/landing/landing.scss | 114 ++++++++- .../src/app/pages/landing/landing.spec.ts | 6 +- .../src/app/pages/landing/landing.ts | 13 +- .../profile-personalisation.html | 23 ++ .../profile-personalisation.scss | 10 +- .../profile-personalisation.spec.ts | 5 +- .../profile-personalisation.ts | 7 +- .../services/user-preferences.service.spec.ts | 90 ++++++++ .../app/services/user-preferences.service.ts | 45 +++- .../src/app/transloco/transloco-loader.ts | 19 ++ .../src/app/transloco/transloco-testing.ts | 32 +++ .../src/app/utils/date-format.spec.ts | 8 + wordkeep-client/src/app/utils/date-format.ts | 12 +- 76 files changed, 2302 insertions(+), 155 deletions(-) create mode 100644 app/Http/Middleware/SetLocale.php create mode 100644 database/migrations/2026_06_02_092335_add_locale_to_user_preferences_table.php create mode 100644 docs/claude-code-memories/frontend-tests-use-npm-test.md create mode 100644 docs/claude-code-memories/i18n-transloco-architecture.md create mode 100644 lang/en/auth.php create mode 100644 lang/en/emails.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/validation.php create mode 100644 lang/ro/auth.php create mode 100644 lang/ro/emails.php create mode 100644 lang/ro/pagination.php create mode 100644 lang/ro/passwords.php create mode 100644 lang/ro/validation.php create mode 100644 scripts/translate-i18n.mjs create mode 100644 tests/Feature/LocalizationTest.php create mode 100644 wordkeep-client/e2e/locale-switch.spec.ts create mode 100644 wordkeep-client/public/assets/i18n/charter/en.json create mode 100644 wordkeep-client/public/assets/i18n/charter/ro.json create mode 100644 wordkeep-client/public/assets/i18n/en.json create mode 100644 wordkeep-client/public/assets/i18n/profile/en.json create mode 100644 wordkeep-client/public/assets/i18n/profile/ro.json create mode 100644 wordkeep-client/public/assets/i18n/ro.json create mode 100644 wordkeep-client/scripts/check-i18n.mjs create mode 100644 wordkeep-client/scripts/i18n-allowlist.txt create mode 100644 wordkeep-client/src/app/transloco/transloco-loader.ts create mode 100644 wordkeep-client/src/app/transloco/transloco-testing.ts diff --git a/.github/workflows/server-and-client-tests.yml b/.github/workflows/server-and-client-tests.yml index 8e112f8..c2afdcf 100644 --- a/.github/workflows/server-and-client-tests.yml +++ b/.github/workflows/server-and-client-tests.yml @@ -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 diff --git a/README.md b/README.md index 5fb9f75..99a32e5 100644 --- a/README.md +++ b/README.md @@ -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 ``, `
@@ -194,69 +194,69 @@

Document Details

@if (doc.description) {
- Description + {{ t('detail.view.description') }}

{{ doc.description }}

} @@ -266,13 +266,13 @@

{{ doc.effective_filename }}

- Edit + {{ t('detail.view.edit') }} diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts b/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts index e3e0367..8a50055 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts @@ -7,6 +7,7 @@ import { DocumentService } from '../../services/document.service'; import { AuthService } from '../../services/auth.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { mockDocument, mockFailedDocument, mockLanguageDetectionResult } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('DocumentDetailComponent', () => { let component: DocumentDetailComponent; @@ -57,7 +58,7 @@ describe('DocumentDetailComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [DocumentDetailComponent], + imports: [DocumentDetailComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.ts b/wordkeep-client/src/app/pages/document-detail/document-detail.ts index 01e255d..1243a9f 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.ts +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { interval, Subscription } from 'rxjs'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService } from '../../services/document.service'; import { BookViewService } from '../../services/book-view.service'; import { ConfirmationService } from '../../services/confirmation.service'; @@ -12,7 +13,8 @@ import { LayoutComponent } from '../../components/layout/layout'; @Component({ selector: 'app-document-detail', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, LayoutComponent], + imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, TranslocoDirective], + providers: [provideTranslocoScope('documents')], templateUrl: './document-detail.html', styleUrl: './document-detail.scss' }) @@ -22,6 +24,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { private documentService = inject(DocumentService); private bookViewService = inject(BookViewService); private confirmationService = inject(ConfirmationService); + private transloco = inject(TranslocoService); document = signal(null); loading = signal(true); @@ -134,7 +137,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.checkAndStartPolling(); }, error: () => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('documents.detail.errors.load')); this.loading.set(false); } }); @@ -244,7 +247,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.saving.set(false); }, error: () => { - this.error.set('Failed to save changes'); + this.error.set(this.transloco.translate('documents.detail.errors.save')); this.saving.set(false); } }); @@ -262,7 +265,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/documents']); }, error: () => { - this.error.set('Failed to delete document'); + this.error.set(this.transloco.translate('documents.detail.errors.delete')); } }); } @@ -283,7 +286,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.detectingLanguage.set(false); }, error: () => { - this.error.set('Failed to detect language'); + this.error.set(this.transloco.translate('documents.detail.errors.detect')); this.detectingLanguage.set(false); } }); @@ -317,7 +320,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.exportingBook.set(false); }, error: () => { - this.error.set('Failed to export document'); + this.error.set(this.transloco.translate('documents.detail.errors.export')); this.exportingBook.set(false); } }); @@ -336,14 +339,28 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { if (missingText > 0 || missingLinks > 0) { const issues: string[] = []; - if (missingText > 0) issues.push(`${missingText} page${missingText > 1 ? 's' : ''} without extracted text`); - if (missingLinks > 0) issues.push(`${missingLinks} unlinked page pair${missingLinks > 1 ? 's' : ''}`); - const message = `This document has: ${issues.join(' and ')}. Missing text will show as placeholders and missing links will default to new row.`; + if (missingText > 0) { + issues.push(this.transloco.translate( + missingText > 1 ? 'documents.detail.incomplete.missingTextPlural' : 'documents.detail.incomplete.missingText', + { count: missingText } + )); + } + if (missingLinks > 0) { + issues.push(this.transloco.translate( + missingLinks > 1 ? 'documents.detail.incomplete.missingLinksPlural' : 'documents.detail.incomplete.missingLinks', + { count: missingLinks } + )); + } + const message = this.transloco.translate('documents.detail.incomplete.message', { + issues: issues.join(this.transloco.translate('documents.detail.incomplete.joiner')) + }); return this.confirmationService.confirm({ - title: 'Work incomplete', + title: this.transloco.translate('documents.detail.incomplete.title'), message, - confirmText: action === 'view' ? 'View anyway' : 'Export anyway', + confirmText: this.transloco.translate( + action === 'view' ? 'documents.detail.incomplete.viewAnyway' : 'documents.detail.incomplete.exportAnyway' + ), }); } diff --git a/wordkeep-client/src/app/pages/documents/documents.html b/wordkeep-client/src/app/pages/documents/documents.html index 61ab8fc..79021d2 100644 --- a/wordkeep-client/src/app/pages/documents/documents.html +++ b/wordkeep-client/src/app/pages/documents/documents.html @@ -1,9 +1,9 @@ -
+
@@ -19,7 +19,7 @@

Documents

@if (uploading()) {
-

Uploading document...

+

{{ t('list.upload.uploading') }}

} @else {
@@ -28,12 +28,12 @@

Documents

-

Drag and drop a PDF file here, or

+

{{ t('list.upload.dragDrop') }}

-

Maximum file size: 100MB

+

{{ t('list.upload.maxSize') }}

}
@@ -56,10 +56,10 @@

Documents

-

Your Documents

+

{{ t('list.section.heading') }}

@if (total() > 0) { - {{ total() }} document{{ total() === 1 ? '' : 's' }} + {{ total() === 1 ? t('list.section.count', { count: total() }) : t('list.section.countPlural', { count: total() }) }} }
@@ -68,7 +68,7 @@

Your Documents

@if (loading()) {
-

Loading documents...

+

{{ t('list.loading') }}

} @else if (documents().length === 0) {
@@ -77,8 +77,8 @@

Your Documents

-

No documents yet

-

Upload your first PDF to get started

+

{{ t('list.empty.title') }}

+

{{ t('list.empty.subtitle') }}

} @else {
@@ -119,14 +119,14 @@

{{ doc.effective_filename }}

{{ doc.effective_filename }}

@if (doc.has_custom_name) { - renamed + {{ t('list.card.renamed') }} }
@if (doc.author) {
{{ doc.author }}
}
- {{ doc.page_count }} page{{ doc.page_count === 1 ? '' : 's' }} + {{ doc.page_count === 1 ? t('list.card.page', { count: doc.page_count }) : t('list.card.pagePlural', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }}
@@ -143,7 +143,7 @@

{{ doc.effective_filename }}

}
@if (doc.status === 'failed') { - } - - - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('list.pagination.pageInfo', { current: currentPage(), last: lastPage() }) }} - -
@if (pageImageUrl()) { @@ -152,16 +153,16 @@

Page {{ currentPage() }} of {{ doc.page_count }}

} @if (isCurrentPageIgnored()) { -
Ignored
+
{{ t('ignoredBadge') }}
} } @else if (loadingImage()) {
-

Loading page...

+

{{ t('loadingPage') }}

} @else {
-

Failed to load page image

+

{{ t('imageError') }}

}
@@ -171,7 +172,7 @@

Page {{ currentPage() }} of {{ doc.page_count }}

-

Extracted Text

+

{{ t('extractedText') }}

@if (currentOcr()?.extraction_method) { Extracted Text - Edit + {{ t('edit') }} } @@ -211,12 +212,12 @@

Extracted Text

> @if (isExtractingCurrentPage()) {
- Extracting... + {{ t('extracting') }} } @else { - Extract Text + {{ t('extractText') }} } } @@ -228,17 +229,17 @@

Extracted Text

> @if (isProcessingCurrentPage()) {
- Processing... + {{ t('processing') }} } @else if (currentOcr()?.ocr_status === 'completed') { - AI OCR + {{ t('aiOcr') }} } @else { - AI OCR + {{ t('aiOcr') }} } @@ -251,7 +252,7 @@

Extracted Text

- Transcribe + {{ t('transcribe') }} } } @@ -280,17 +281,17 @@

Extracted Text

}
} @@ -302,8 +303,8 @@

Extracted Text

-

This page is ignored

-

It will not appear in the exported document. Click "Un-ignore" to re-enable text extraction.

+

{{ t('ignoredHeading') }}

+

{{ t('ignoredHint') }}

} @else if (editMode()) {
@@ -312,45 +313,45 @@

Extracted Text

[editor]="editor" [(ngModel)]="editorContent" (ngModelChange)="onEditorContentChange()" - [placeholder]="'Enter text...'" + [placeholder]="t('editorPlaceholder')" >
} @else if (isProcessingCurrentPage()) {
-

Processing page with AI OCR...

-

This may take a moment

+

{{ t('processingHeading') }}

+

{{ t('processingHint') }}

} @else if (isExtractingCurrentPage()) {
-

Extracting text from PDF...

-

This should be quick

+

{{ t('extractingHeading') }}

+

{{ t('extractingHint') }}

} @else if (currentOcr()?.ocr_text) {
@if (currentOcr()?.cached) { -
Cached result
+
{{ t('cached') }}
} } @else if (currentOcr()?.ocr_status === 'failed') {
-

OCR processing failed

+

{{ t('ocrFailed') }}

} @else {
-

No text available

-

Use the buttons above or transcribe manually

+

{{ t('noText') }}

+

{{ t('noTextHint') }}

} @@ -359,7 +360,7 @@

Extracted Text

-

Footnotes

+

{{ t('footnotes') }}

@if (currentFootnote()?.page_number !== null && currentFootnote()?.page_number !== undefined) { } } } @else { } @@ -450,7 +451,7 @@

Footnotes

@if (loadingFootnotes()) {
-

Loading footnotes...

+

{{ t('loadingFootnotes') }}

} @else if (footnoteEditMode()) {
@@ -458,7 +459,7 @@

Footnotes

} @else if (currentFootnote(); as fn) { @@ -467,18 +468,18 @@

Footnotes

- This footnote is on page {{ fn.page_number }} + {{ t('footnoteOnPage', { page: fn.page_number }) }}
}
[^{{ fn.number }}] @if (fn.page_number) { - Page {{ fn.page_number }} + {{ t('footnotePageBadge', { page: fn.page_number }) }} } @else { - Unlinked + {{ t('footnoteUnlinked') }} }
@@ -487,8 +488,8 @@

Footnotes

-

No footnotes yet

-

Click "Add Footnote" to create one

+

{{ t('noFootnotes') }}

+

{{ t('noFootnotesHint') }}

}
@@ -496,4 +497,5 @@

Footnotes

}
+
diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts index 5349b49..d921595 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts @@ -8,6 +8,7 @@ import { AuthService } from '../../services/auth.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { mockDocument, mockFailedDocument, mockOcrResult, mockPageInfoText, mockPageInfoImage, mockPageInfoHybrid, mockTextExtractionResult, mockFootnote, mockLinkedFootnote } from '../../test-utils'; import { Footnote } from '../../models/document.model'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('ExtractionReaderComponent', () => { let component: ExtractionReaderComponent; @@ -81,7 +82,7 @@ describe('ExtractionReaderComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [ExtractionReaderComponent], + imports: [ExtractionReaderComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts index 1980453..1c4396f 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService, PageInfoResult } from '../../services/document.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { SpellCheckService, NspellChecker } from '../../services/spell-check.service'; @@ -20,9 +21,10 @@ import TurndownService from 'turndown'; @Component({ selector: 'app-extraction-reader', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule], + imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule, TranslocoDirective], templateUrl: './extraction-reader.html', - styleUrl: './extraction-reader.scss' + styleUrl: './extraction-reader.scss', + providers: [provideTranslocoScope('extraction')] }) export class ExtractionReaderComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); @@ -35,6 +37,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private elementRef = inject(ElementRef); private sanitizer = inject(DomSanitizer); + private transloco = inject(TranslocoService); private spellClickSubject = new Subject(); private currentChecker: NspellChecker | null = null; @@ -193,10 +196,10 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { }); insertFootnoteTooltip = computed((): string => { - if (!this.editMode()) return 'Enter edit mode first'; - if (this.footnotes().length === 0) return 'No footnotes exist yet'; - if (!this.canInsertFootnoteRef()) return 'All footnotes already referenced'; - return 'Insert footnote reference'; + if (!this.editMode()) return this.transloco.translate('extraction.reader.insertFootnoteTooltip.enterEditFirst'); + if (this.footnotes().length === 0) return this.transloco.translate('extraction.reader.insertFootnoteTooltip.noFootnotes'); + if (!this.canInsertFootnoteRef()) return this.transloco.translate('extraction.reader.insertFootnoteTooltip.allReferenced'); + return this.transloco.translate('extraction.reader.insertFootnoteTooltip.insert'); }); footnotePageMismatch = computed((): boolean => { @@ -356,7 +359,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadFootnotes(); }, error: () => { - this.error.set('Failed to unlink orphaned footnotes'); + this.error.set(this.transloco.translate('extraction.reader.errors.unlinkOrphanFailed')); this.saving.set(false); this.loadFootnotes(); } @@ -367,7 +370,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { } }, error: (err: any) => { - const msg = err?.error?.message || 'Failed to save changes'; + const msg = err?.error?.message || this.transloco.translate('extraction.reader.errors.saveFailed'); this.error.set(msg); this.saving.set(false); } @@ -405,7 +408,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadOcrForCurrentPage(); }, error: () => { - this.error.set('Failed to update page ignore status'); + this.error.set(this.transloco.translate('extraction.reader.errors.ignoreStatusFailed')); this.togglingIgnore.set(false); } }); @@ -416,9 +419,9 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { if (!doc) return; const confirmed = await this.confirmationService.confirm({ - title: 'Clear text', - message: 'Delete extracted text for this page?', - confirmText: 'Clear', + title: this.transloco.translate('extraction.reader.confirm.clearTitle'), + message: this.transloco.translate('extraction.reader.confirm.clearMessage'), + confirmText: this.transloco.translate('extraction.reader.confirm.clearConfirm'), isDanger: true }); if (!confirmed) return; @@ -434,7 +437,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadFootnotes(); }, error: () => { - this.error.set('Failed to clear text'); + this.error.set(this.transloco.translate('extraction.reader.errors.clearFailed')); this.clearing.set(false); } }); @@ -460,7 +463,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { } this.pageImageUrl.set(null); this.loadingImage.set(false); - this.error.set('Failed to load page image'); + this.error.set(this.transloco.translate('extraction.reader.errors.loadImageFailed')); } }); } @@ -502,7 +505,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.checkAndStartLangPolling(); }, error: (err) => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('extraction.reader.errors.loadDocumentFailed')); this.loading.set(false); } }); @@ -625,7 +628,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { const updatedProcessing = new Set(this.processingOcr()); updatedProcessing.delete(pageNum); this.processingOcr.set(updatedProcessing); - this.error.set('Failed to process OCR'); + this.error.set(this.transloco.translate('extraction.reader.errors.processOcrFailed')); } }); } @@ -658,7 +661,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { const updatedExtracting = new Set(this.extractingText()); updatedExtracting.delete(pageNum); this.extractingText.set(updatedExtracting); - this.error.set('Failed to extract text'); + this.error.set(this.transloco.translate('extraction.reader.errors.extractTextFailed')); } }); } @@ -733,7 +736,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { const markdown = this.turndownService.turndown(this.footnoteEditorContent); if (!markdown.trim()) { - this.error.set('Footnote text cannot be empty'); + this.error.set(this.transloco.translate('extraction.reader.errors.footnoteEmpty')); return; } @@ -750,7 +753,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.savingFootnote.set(false); }, error: () => { - this.error.set('Failed to create footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.createFootnoteFailed')); this.savingFootnote.set(false); } }); @@ -768,7 +771,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.savingFootnote.set(false); }, error: () => { - this.error.set('Failed to update footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.updateFootnoteFailed')); this.savingFootnote.set(false); } }); @@ -781,9 +784,9 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { if (!doc || !fn) return; const confirmed = await this.confirmationService.confirm({ - title: 'Delete footnote', - message: `Delete footnote [^${fn.number}]? References will be removed from page texts.`, - confirmText: 'Delete', + title: this.transloco.translate('extraction.reader.confirm.deleteFootnoteTitle'), + message: this.transloco.translate('extraction.reader.confirm.deleteFootnoteMessage', { number: fn.number }), + confirmText: this.transloco.translate('extraction.reader.confirm.deleteFootnoteConfirm'), isDanger: true }); if (!confirmed) return; @@ -808,7 +811,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadOcrForCurrentPage(); }, error: () => { - this.error.set('Failed to delete footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.deleteFootnoteFailed')); this.deletingFootnote.set(false); } }); @@ -820,9 +823,9 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { if (!doc || !fn || fn.page_number === null) return; const confirmed = await this.confirmationService.confirm({ - title: 'Unlink footnote', - message: `Remove [^${fn.number}] reference from page ${fn.page_number} text?`, - confirmText: 'Unlink', + title: this.transloco.translate('extraction.reader.confirm.unlinkFootnoteTitle'), + message: this.transloco.translate('extraction.reader.confirm.unlinkFootnoteMessage', { number: fn.number, page: fn.page_number }), + confirmText: this.transloco.translate('extraction.reader.confirm.unlinkFootnoteConfirm'), isDanger: true }); if (!confirmed) return; @@ -839,7 +842,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadOcrForCurrentPage(); }, error: () => { - this.error.set('Failed to unlink footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.unlinkFootnoteFailed')); this.unlinkingFootnote.set(false); } }); @@ -964,10 +967,10 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { getContentTypeLabel(contentType: ContentType | null): string { switch (contentType) { - case 'text': return 'Text'; - case 'image': return 'Scanned'; - case 'hybrid': return 'Mixed'; - default: return 'Unknown'; + case 'text': return this.transloco.translate('extraction.reader.contentType.text'); + case 'image': return this.transloco.translate('extraction.reader.contentType.image'); + case 'hybrid': return this.transloco.translate('extraction.reader.contentType.hybrid'); + default: return this.transloco.translate('extraction.reader.contentType.unknown'); } } @@ -982,27 +985,27 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { getContentTypeTooltip(contentType: ContentType | null): string { switch (contentType) { - case 'text': return 'Text page. "Extract Text" = fast, plain text. "AI OCR" = slower, formatted Markdown.'; - case 'image': return 'Scanned page. Use "AI OCR" to extract text using AI vision.'; - case 'hybrid': return 'Mixed page (text + images). "Extract Text" = fast, plain text. "AI OCR" = formatted with image content.'; + case 'text': return this.transloco.translate('extraction.reader.contentTypeTooltip.text'); + case 'image': return this.transloco.translate('extraction.reader.contentTypeTooltip.image'); + case 'hybrid': return this.transloco.translate('extraction.reader.contentTypeTooltip.hybrid'); default: return ''; } } getExtractionMethodLabel(method: string | null | undefined): string { switch (method) { - case 'text_extraction': return 'Direct'; - case 'ocr': return 'OCR'; - case 'manual': return 'Manual'; + case 'text_extraction': return this.transloco.translate('extraction.reader.extractionMethod.text_extraction'); + case 'ocr': return this.transloco.translate('extraction.reader.extractionMethod.ocr'); + case 'manual': return this.transloco.translate('extraction.reader.extractionMethod.manual'); default: return ''; } } getExtractionMethodTooltip(method: string | null | undefined): string { switch (method) { - case 'text_extraction': return 'Extracted directly from PDF (fast, plain text only)'; - case 'ocr': return 'Extracted using AI vision (preserves formatting: bold, headings, paragraphs)'; - case 'manual': return 'Manually transcribed by user'; + case 'text_extraction': return this.transloco.translate('extraction.reader.extractionMethodTooltip.text_extraction'); + case 'ocr': return this.transloco.translate('extraction.reader.extractionMethodTooltip.ocr'); + case 'manual': return this.transloco.translate('extraction.reader.extractionMethodTooltip.manual'); default: return ''; } } @@ -1016,45 +1019,15 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { } getLanguageDisplayName(code: string | null): string { - if (!code) return 'Not detected'; - const languages: Record = { - 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' - }; - return languages[code] || code.toUpperCase(); + if (!code) return this.transloco.translate('extraction.reader.language.notDetected'); + const knownCodes = [ + 'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', + 'ro', 'nl', 'pl', 'sv', 'da', 'no', 'fi', 'cs', 'el', 'tr', 'he', 'th', + 'vi', 'id', 'ms', 'la', 'uk', 'hu', 'bg', 'hr', 'sk', 'sl', 'sr', 'unknown' + ]; + if (knownCodes.includes(code)) { + return this.transloco.translate(`extraction.reader.language.${code}`); + } + return code.toUpperCase(); } } diff --git a/wordkeep-client/src/app/pages/extraction/extraction.html b/wordkeep-client/src/app/pages/extraction/extraction.html index f1f1239..15722e9 100644 --- a/wordkeep-client/src/app/pages/extraction/extraction.html +++ b/wordkeep-client/src/app/pages/extraction/extraction.html @@ -1,9 +1,10 @@ +
@@ -25,10 +26,10 @@

Text Extraction

-

Your Documents

+

{{ t('yourDocuments') }}

@if (total() > 0) { - {{ total() }} document{{ total() === 1 ? '' : 's' }} + {{ t('docCount', { count: total() }) }} }
@@ -37,7 +38,7 @@

Your Documents

@if (loading()) {
-

Loading documents...

+

{{ t('loading') }}

} @else if (documents().length === 0) {
@@ -46,8 +47,8 @@

Your Documents

-

No documents yet

-

Upload documents in the Documents section first

+

{{ t('emptyTitle') }}

+

{{ t('emptySubtitle') }}

} @else {
@@ -63,11 +64,11 @@

No documents yet

{{ doc.effective_filename }}

@if (doc.has_custom_name) { - renamed + {{ t('renamed') }} }
- {{ doc.page_count }} page{{ doc.page_count === 1 ? '' : 's' }} + {{ t('pageCount', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }}
@@ -75,7 +76,7 @@

{{ doc.effective_filename }}

- {{ doc.ocr_progress?.completed ?? 0 }}/{{ doc.page_count }} extracted ({{ doc.ocr_progress?.percent ?? 0 }}%) + {{ t('ocrProgress', { completed: doc.ocr_progress?.completed ?? 0, total: doc.page_count, percent: doc.ocr_progress?.percent ?? 0 }) }}
@@ -89,7 +90,7 @@

{{ doc.effective_filename }}

-
} @else if (doc.status === 'ready') {
-
} @@ -126,15 +127,15 @@

{{ doc.effective_filename }}

- Previous + {{ t('previous') }} - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('pageOf', { current: currentPage(), last: lastPage() }) }} @@ -59,7 +60,7 @@

{{ document()!.effective_filename }}

- All done! All page pairs have been linked. + {{ t('reader.allDone') }} } @@ -73,12 +74,12 @@

{{ document()!.effective_filename }}

@@ -97,12 +98,12 @@

{{ document()!.effective_filename }}

} @else if (imageUrlA()) { - Page {{ currentPair() }} + @if (isPageAIgnored()) { -
Ignored
+
{{ t('reader.ignoredOverlay') }}
} } @else { -
Image unavailable
+
{{ t('reader.imageUnavailable') }}
}
@@ -117,14 +118,14 @@

{{ document()!.effective_filename }}

+
diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts index 044dab0..57656b5 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts @@ -6,6 +6,7 @@ import { PageLinkingReaderComponent } from './page-linking-reader'; import { DocumentService } from '../../services/document.service'; import { AuthService } from '../../services/auth.service'; import { mockDocument, mockPageLinkPair } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('PageLinkingReaderComponent', () => { let component: PageLinkingReaderComponent; @@ -54,7 +55,7 @@ describe('PageLinkingReaderComponent', () => { }; await TestBed.configureTestingModule({ - imports: [PageLinkingReaderComponent], + imports: [PageLinkingReaderComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts index 04baf40..5a26a15 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts @@ -1,6 +1,7 @@ import { Component, OnInit, OnDestroy, inject, signal, computed, effect, DestroyRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService } from '../../services/document.service'; import { Document, PageLinkPair, PageLinkDecision } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; @@ -8,7 +9,8 @@ import { LayoutComponent } from '../../components/layout/layout'; @Component({ selector: 'app-page-linking-reader', standalone: true, - imports: [CommonModule, RouterLink, LayoutComponent], + imports: [CommonModule, RouterLink, LayoutComponent, TranslocoDirective], + providers: [provideTranslocoScope('pageLinking')], templateUrl: './page-linking-reader.html', styleUrl: './page-linking-reader.scss' }) @@ -16,6 +18,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private documentService = inject(DocumentService); private destroyRef = inject(DestroyRef); + private transloco = inject(TranslocoService); document = signal(null); loading = signal(true); @@ -96,7 +99,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { this.loading.set(false); }, error: () => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('pageLinking.reader.errorLoadDocument')); this.loading.set(false); } }); @@ -120,7 +123,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { this.loadingPair.set(false); }, error: () => { - this.error.set('Failed to load page data'); + this.error.set(this.transloco.translate('pageLinking.reader.errorLoadPageData')); this.loadingPair.set(false); } }); @@ -181,7 +184,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { } }, error: () => { - this.error.set('Failed to save. Please try again.'); + this.error.set(this.transloco.translate('pageLinking.reader.errorSave')); this.saving.set(false); } }); @@ -240,7 +243,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { this.loadPairData(doc.id, this.currentPair()); }, error: () => { - this.error.set('Failed to update page ignore status'); + this.error.set(this.transloco.translate('pageLinking.reader.errorIgnore')); if (side === 'a') { this.togglingIgnoreA.set(false); } else { diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.html b/wordkeep-client/src/app/pages/page-linking/page-linking.html index 331ac20..862c050 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.html +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.html @@ -1,9 +1,10 @@ +
@@ -25,10 +26,10 @@

Page Linking

-

Your Documents

+

{{ t('list.documentsHeading') }}

@if (total() > 0) { - {{ total() }} document{{ total() === 1 ? '' : 's' }} + {{ total() === 1 ? t('list.docCount', { count: total() }) : t('list.docCountPlural', { count: total() }) }} }
@@ -37,7 +38,7 @@

Your Documents

@if (loading()) {
-

Loading documents...

+

{{ t('list.loading') }}

} @else if (documents().length === 0) {
@@ -46,8 +47,8 @@

Your Documents

-

No documents ready

-

Only ready documents with more than one page appear here

+

{{ t('list.emptyTitle') }}

+

{{ t('list.emptySubtitle') }}

} @else {
@@ -63,11 +64,11 @@

No documents ready

{{ doc.effective_filename }}

@if (doc.has_custom_name) { - renamed + {{ t('list.renamedBadge') }} }
- {{ doc.page_count }} pages + {{ t('list.pages', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }}
@@ -88,7 +89,7 @@

{{ doc.effective_filename }}

[routerLink]="['/page-linking', doc.id]" [queryParams]="{ page: getResumePage(doc) }" > - Link + {{ t('list.linkAction') }}
@@ -106,15 +107,15 @@

{{ doc.effective_filename }}

- Previous + {{ t('list.previous') }} - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('list.pageInfo', { current: currentPage(), last: lastPage() }) }}
+
diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.ts b/wordkeep-client/src/app/pages/page-linking/page-linking.ts index a7041aa..12d0bec 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.ts +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.ts @@ -1,6 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService } from '../../services/document.service'; import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; @@ -10,13 +11,15 @@ import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-page-linking', standalone: true, - imports: [CommonModule, RouterLink, LayoutComponent, SortDropdownComponent], + imports: [CommonModule, RouterLink, LayoutComponent, SortDropdownComponent, TranslocoDirective], + providers: [provideTranslocoScope('pageLinking')], templateUrl: './page-linking.html', styleUrl: './page-linking.scss' }) export class PageLinkingComponent implements OnInit { private documentService = inject(DocumentService); private viewStates = inject(UserViewStatesService); + private transloco = inject(TranslocoService); documents = signal([]); loading = signal(true); @@ -49,7 +52,7 @@ export class PageLinkingComponent implements OnInit { this.loading.set(false); }, error: () => { - this.error.set('Failed to load documents'); + this.error.set(this.transloco.translate('pageLinking.list.errorLoad')); this.loading.set(false); } }); @@ -86,7 +89,7 @@ export class PageLinkingComponent implements OnInit { getLinksProgressText(doc: Document): string { const p = doc.links_progress; if (!p) return ''; - return `${p.completed} / ${p.total} pages linked`; + return this.transloco.translate('pageLinking.list.progressText', { completed: p.completed, total: p.total }); } getLinksPercent(doc: Document): number { diff --git a/wordkeep-client/src/app/pages/profile/info/profile-info.html b/wordkeep-client/src/app/pages/profile/info/profile-info.html index 1104783..47f67c2 100644 --- a/wordkeep-client/src/app/pages/profile/info/profile-info.html +++ b/wordkeep-client/src/app/pages/profile/info/profile-info.html @@ -1,27 +1,28 @@ +
-

Profile Info

+

{{ t('info.title') }}

@if (user()?.avatar_url) { - Profile photo + } @else { {{ (user()?.name ?? '').charAt(0).toUpperCase() }} }

- {{ user()?.avatar_url ? 'Update your profile photo.' : 'Add a profile photo.' }} + {{ user()?.avatar_url ? t('info.avatar.hintUpdate') : t('info.avatar.hintAdd') }}

@if (user()?.avatar_url) { }
@@ -31,13 +32,13 @@

Profile Info

-
Name
+
{{ t('info.name.label') }}
@if (!editingName()) {
{{ user()?.name }}
} @else { @@ -48,7 +49,7 @@

Profile Info

[(ngModel)]="nameValue" [ngModel]="nameValue()" (ngModelChange)="nameValue.set($event)" - placeholder="Your name" + [placeholder]="t('info.name.placeholder')" maxlength="255" (keyup.enter)="saveName()" (keyup.escape)="cancelEditingName()" @@ -59,9 +60,9 @@

Profile Info

}
- +
} @@ -69,13 +70,13 @@

Profile Info

-
Email
+
{{ t('info.email.label') }}
{{ user()?.email }} @if (!user()?.pending_email) { }
@@ -84,18 +85,18 @@

Profile Info

- Pending change to {{ user()!.pending_email }} - Check your new inbox for the verification link. Expires {{ formatExpiry(user()!.pending_email_expires_at) }}. + + {{ t('info.email.pendingHint', { date: formatExpiry(user()!.pending_email_expires_at) }) }} @if (resendSuccess()) { - Verification email resent. + {{ t('info.email.resent') }} }
@@ -116,7 +117,7 @@

Profile Info

}
@@ -422,7 +423,7 @@

{{ getLanguageName(targetLanguage()) }}

-
Original
+
{{ t('reader.originalLabel') }}
@if (activeFootnote()!.translated_text) { @@ -431,7 +432,7 @@

{{ getLanguageName(targetLanguage()) }}

} @else { -
No translation yet
+
{{ t('reader.fnNoTranslation') }}
}
} + diff --git a/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts b/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts index a8c2638..44ef0dd 100644 --- a/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts +++ b/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts @@ -10,6 +10,7 @@ import { ConfirmationService } from '../../services/confirmation.service'; import { SpellCheckService } from '../../services/spell-check.service'; import { AuthService } from '../../services/auth.service'; import { Document, OcrResult, PageTranslation, FootnoteWithTranslation } from '../../models/document.model'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; const makeFootnote = (overrides: any = {}): FootnoteWithTranslation => ({ id: 1, @@ -116,7 +117,7 @@ describe('TranslationReaderComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [TranslationReaderComponent], + imports: [TranslationReaderComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { diff --git a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts index 786bc29..6dc7f03 100644 --- a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts +++ b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts @@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy, HostListener, inject, signal, computed, e import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DocumentService } from '../../services/document.service'; import { TranslationService } from '../../services/translation.service'; @@ -45,7 +46,8 @@ interface SplitSegment { @Component({ selector: 'app-translation-reader', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule], + imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule, TranslocoDirective], + providers: [provideTranslocoScope('translation')], templateUrl: './translation-reader.html', styleUrl: './translation-reader.scss' }) @@ -60,6 +62,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { private injector = inject(EnvironmentInjector); private destroyRef = inject(DestroyRef); private sanitizer = inject(DomSanitizer); + private transloco = inject(TranslocoService); private spellClickSubject = new Subject(); private currentChecker: NspellChecker | null = null; @@ -370,7 +373,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.currentPage.set(wsPage); }, error: () => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('translation.reader.errors.loadDocument')); this.loading.set(false); } }); @@ -405,7 +408,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.documentService.updateWorkStatus(doc.id, 'translation_' + this.targetLanguage(), page).subscribe(); }, error: () => { - this.error.set('Failed to load page data'); + this.error.set(this.transloco.translate('translation.reader.errors.loadPage')); this.loadingPage.set(false); } }); @@ -444,7 +447,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { next: (page) => { this.navigatingUntranslated.set(false); if (page !== null) this.goToPage(page); - else this.error.set('All available pages have been translated.'); + else this.error.set(this.transloco.translate('translation.reader.errors.allTranslated')); }, error: () => this.navigatingUntranslated.set(false) }); @@ -458,7 +461,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { next: (page) => { this.navigatingUntranslated.set(false); if (page !== null) this.goToPage(page); - else this.error.set('No more untranslated pages after this one.'); + else this.error.set(this.transloco.translate('translation.reader.errors.noMoreUntranslated')); }, error: () => this.navigatingUntranslated.set(false) }); @@ -487,7 +490,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.updateCachedTranslation(page, result); this.wrongLanguagePages.update(pages => pages.filter(p => p !== page)); }, - error: () => this.error.set('Failed to dismiss.') + error: () => this.error.set(this.transloco.translate('translation.reader.errors.dismiss')) }); } @@ -506,10 +509,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { const targetLang = this.getLanguageName(this.targetLanguage()); const confirmed = await this.confirmationService.confirm({ - title: 'Retry with stronger instructions?', - message: `Retry translating to ${targetLang} with a stronger prompt. Estimated cost: ~${costStr}`, - confirmText: 'Retry', - cancelText: 'Cancel', + title: this.transloco.translate('translation.reader.confirm.retryTitle'), + message: this.transloco.translate('translation.reader.confirm.retryMessage', { language: targetLang, cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -544,10 +547,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { const hasExisting = !!data.translation; const confirmed = await this.confirmationService.confirm({ - title: hasExisting ? 'Re-translate page?' : 'Translate page with AI?', - message: (hasExisting ? 'Existing translation will be replaced. ' : '') + `Estimated cost: ~${costStr} (rough estimate based on character count)`, - confirmText: 'Translate', - cancelText: 'Cancel', + title: this.transloco.translate(hasExisting ? 'translation.reader.confirm.reTranslatePageTitle' : 'translation.reader.confirm.translatePageTitle'), + message: this.transloco.translate('translation.reader.confirm.translatePageMessage', { + replace: hasExisting ? this.transloco.translate('translation.reader.confirm.translatePageReplace') : '', + cost: costStr, + }), + confirmText: this.transloco.translate('translation.reader.confirm.translate'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -587,10 +593,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { const sourceLang = this.getLanguageName(doc.main_language ?? null); const targetLang = this.getLanguageName(this.targetLanguage()); const retryConfirmed = await this.confirmationService.confirm({ - title: 'Translation returned wrong language', - message: `Gemini returned the text in ${sourceLang} instead of ${targetLang}. Retry with stronger instructions? Estimated cost: ~${costStr} (rough estimate)`, - confirmText: 'Retry', - cancelText: 'Keep', + title: this.transloco.translate('translation.reader.confirm.wrongLangTitle'), + message: this.transloco.translate('translation.reader.confirm.wrongLangMessage', { source: sourceLang, target: targetLang, cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.keep'), }); if (!retryConfirmed) return; @@ -622,7 +628,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.updateCachedTranslation(page, err.error.data); this.reloadFootnotes(); } - this.error.set(err.error?.message ?? 'Translation failed.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.translationFailed')); this.translating.set(false); } @@ -667,7 +673,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.saving.set(false); }, error: (err) => { - this.error.set(err.error?.message ?? 'Failed to save.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.save')); this.saving.set(false); } }); @@ -675,10 +681,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { async clearTranslation() { const confirmed = await this.confirmationService.confirm({ - title: 'Clear translation?', - message: 'The translation for this page will be permanently deleted.', - confirmText: 'Clear', - cancelText: 'Cancel', + title: this.transloco.translate('translation.reader.confirm.clearTitle'), + message: this.transloco.translate('translation.reader.confirm.clearMessage'), + confirmText: this.transloco.translate('translation.reader.confirm.clear'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -691,7 +697,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.clearing.set(false); }, error: () => { - this.error.set('Failed to clear translation.'); + this.error.set(this.transloco.translate('translation.reader.errors.clear')); this.clearing.set(false); } }); @@ -781,10 +787,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { if (!this.allSegmentsOk()) { const n = this.oversizedSegmentsCount(); const confirmed = await this.confirmationService.confirm({ - title: 'Some chunks are still too long', - message: `${n} chunk${n > 1 ? 's are' : ' is'} over 3000 words and may produce incomplete translations. Send anyway?`, - confirmText: 'Send anyway', - cancelText: 'Go back', + title: this.transloco.translate('translation.reader.confirm.splitsTooLongTitle'), + message: this.transloco.translate( + n > 1 ? 'translation.reader.confirm.splitsTooLongMessageMany' : 'translation.reader.confirm.splitsTooLongMessageOne', + { count: n } + ), + confirmText: this.transloco.translate('translation.reader.confirm.sendAnyway'), + cancelText: this.transloco.translate('translation.reader.confirm.goBack'), }); if (!confirmed) return; } @@ -799,7 +808,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { void this.executeTranslation(); }, error: (err) => { - this.error.set(err.error?.message ?? 'Failed to save splits.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.saveSplits')); this.savingSplits.set(false); } }); @@ -810,7 +819,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { } getLanguageName(code: string | null): string { - if (!code) return 'Original'; + if (!code) return this.transloco.translate('translation.reader.originalLabel'); return LANGUAGE_NAMES[code] ?? code; } @@ -828,10 +837,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { if (fn.wrong_language && !forceRetry) { // Wrong language detected — go straight to retry confirmation const retryConfirmed = await this.confirmationService.confirm({ - title: `Retry footnote [^${fn.number}] with stronger instructions?`, - message: `Gemini returned this footnote in the source language instead of ${this.getLanguageName(this.targetLanguage())}. Retry with a stronger prompt? Estimated cost: ~${costStr}`, - confirmText: 'Retry', - cancelText: 'Keep', + title: this.transloco.translate('translation.reader.confirm.fnRetryTitle', { number: fn.number }), + message: this.transloco.translate('translation.reader.confirm.fnRetryMessage', { language: this.getLanguageName(this.targetLanguage()), cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.keep'), }); if (!retryConfirmed) return; void this.executeFootnoteTranslation(doc.id, fn, true); @@ -839,10 +848,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { } const confirmed = await this.confirmationService.confirm({ - title: fn.translated_text ? `Re-translate footnote [^${fn.number}]?` : `Translate footnote [^${fn.number}]?`, - message: (fn.translated_text ? 'The existing footnote translation will be replaced. ' : '') + `Estimated cost: ~${costStr} (rough estimate based on character count)`, - confirmText: 'Translate', - cancelText: 'Cancel', + title: this.transloco.translate(fn.translated_text ? 'translation.reader.confirm.fnReTranslateTitle' : 'translation.reader.confirm.fnTranslateTitle', { number: fn.number }), + message: this.transloco.translate('translation.reader.confirm.fnMessage', { + replace: fn.translated_text ? this.transloco.translate('translation.reader.confirm.fnReplace') : '', + cost: costStr, + }), + confirmText: this.transloco.translate('translation.reader.confirm.translate'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -862,17 +874,17 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { if (updated.wrong_language) { const retryConfirmed = await this.confirmationService.confirm({ - title: `Footnote [^${fn.number}] returned wrong language`, - message: `Gemini returned this footnote in the source language instead of ${this.getLanguageName(this.targetLanguage())}. Retry with stronger instructions? Estimated cost: ~${costStr}`, - confirmText: 'Retry', - cancelText: 'Keep', + title: this.transloco.translate('translation.reader.confirm.fnWrongLangTitle', { number: fn.number }), + message: this.transloco.translate('translation.reader.confirm.fnWrongLangMessage', { language: this.getLanguageName(this.targetLanguage()), cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.keep'), }); if (!retryConfirmed) return; void this.executeFootnoteTranslation(docId, fn, true); } }, error: (err) => { - this.error.set(err.error?.message ?? 'Footnote translation failed.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.footnoteFailed')); this.translatingFootnote.set(false); } }); @@ -886,7 +898,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.footnotes.update(fns => fns.map(f => f.id === updated.id ? updated : f)); if (this.activeFootnote()?.id === updated.id) this.activeFootnote.set(updated); }, - error: () => this.error.set('Failed to dismiss.') + error: () => this.error.set(this.transloco.translate('translation.reader.errors.dismiss')) }); } diff --git a/wordkeep-client/src/app/pages/translation/translation.html b/wordkeep-client/src/app/pages/translation/translation.html index 3934489..a95e00c 100644 --- a/wordkeep-client/src/app/pages/translation/translation.html +++ b/wordkeep-client/src/app/pages/translation/translation.html @@ -1,15 +1,16 @@ +
@@ -29,10 +30,10 @@

Translation

-

Your Translations

+

{{ t('list.yourTranslations') }}

@if (total() > 0) { - {{ total() }} translation{{ total() === 1 ? '' : 's' }} + {{ total() === 1 ? t('list.count', { count: total() }) : t('list.countPlural', { count: total() }) }} }
@@ -41,7 +42,7 @@

Your Translations

@if (loading()) {
-

Loading translations...

+

{{ t('list.loading') }}

} @else if (translations().length === 0) {
@@ -50,13 +51,13 @@

Your Translations

-

No translations yet

-

Click "New Translation" to translate a document

+

{{ t('list.emptyTitle') }}

+

{{ t('list.emptyBody') }}

} @else {
- @for (t of translations(); track t.id) { -
+ @for (tr of translations(); track tr.id) { +
@@ -65,47 +66,47 @@

No translations yet

-

{{ t.document?.effective_filename ?? 'Document #' + t.document_id }}

- {{ getLanguageName(t.target_language) }} - @if ((t.wrong_language_count ?? 0) > 0) { - {{ t.wrong_language_count }} pages with wrong language +

{{ tr.document?.effective_filename ?? t('list.documentFallback', { id: tr.document_id }) }}

+ {{ getLanguageName(tr.target_language) }} + @if ((tr.wrong_language_count ?? 0) > 0) { + {{ t('list.wrongLangPages', { count: tr.wrong_language_count }) }} } - @if ((t.wrong_language_footnote_count ?? 0) > 0) { - {{ t.wrong_language_footnote_count }} footnotes with wrong language + @if ((tr.wrong_language_footnote_count ?? 0) > 0) { + {{ t('list.wrongLangFootnotes', { count: tr.wrong_language_footnote_count }) }} }
- {{ formatDate(t.created_at) }} - @if (t.document) { + {{ formatDate(tr.created_at) }} + @if (tr.document) { - {{ t.document.page_count }} page{{ t.document.page_count === 1 ? '' : 's' }} + {{ tr.document.page_count === 1 ? t('list.page', { count: tr.document.page_count }) : t('list.pagePlural', { count: tr.document.page_count }) }} }
-
+
- {{ t.pages_translated_actual ?? 0 }}/{{ t.document?.page_count ?? 0 }} translated ({{ getIdleProgress(t) }}%) + {{ t('list.translatedProgress', { done: tr.pages_translated_actual ?? 0, total: tr.document?.page_count ?? 0, percent: getIdleProgress(tr) }) }}
- @if (t.status === 'failed' && t.error_message) { -

{{ t.error_message }}

+ @if (tr.status === 'failed' && tr.error_message) { +

{{ tr.error_message }}

}
- @if (!isRunning(t)) { - {{ formatDate(t.updated_at) }} + @if (!isRunning(tr)) { + {{ formatDate(tr.updated_at) }} }
- @if (isRunning(t)) { + @if (isRunning(tr)) {
- {{ t.pages_completed }}/{{ t.pages_total }} + {{ tr.pages_completed }}/{{ tr.pages_total }}
-
+
- @if (t.status === 'processing') { -
} @else {
-
- - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('list.pageInfo', { current: currentPage(), last: lastPage() }) }}
+ diff --git a/wordkeep-client/src/app/pages/translation/translation.ts b/wordkeep-client/src/app/pages/translation/translation.ts index 4aedc98..c2e1e5d 100644 --- a/wordkeep-client/src/app/pages/translation/translation.ts +++ b/wordkeep-client/src/app/pages/translation/translation.ts @@ -1,6 +1,7 @@ import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { interval, Subscription } from 'rxjs'; import { TranslationService } from '../../services/translation.service'; import { ConfirmationService } from '../../services/confirmation.service'; @@ -15,7 +16,8 @@ import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-translation', standalone: true, - imports: [CommonModule, LayoutComponent, SortDropdownComponent], + imports: [CommonModule, LayoutComponent, SortDropdownComponent, TranslocoDirective], + providers: [provideTranslocoScope('translation')], templateUrl: './translation.html', styleUrl: './translation.scss' }) @@ -25,6 +27,7 @@ export class TranslationComponent implements OnInit, OnDestroy { private newTranslationDialog = inject(NewTranslationDialogService); private router = inject(Router); private viewStates = inject(UserViewStatesService); + private transloco = inject(TranslocoService); private pollSubscription?: Subscription; private dispatchedIds = new Set(); @@ -65,7 +68,7 @@ export class TranslationComponent implements OnInit, OnDestroy { this.checkAndStartPolling(); }, error: () => { - this.error.set('Failed to load translations'); + this.error.set(this.transloco.translate('translation.list.errors.load')); this.loading.set(false); } }); @@ -108,7 +111,7 @@ export class TranslationComponent implements OnInit, OnDestroy { this.loadTranslations(this.currentPage()); }, error: () => { - this.error.set('Failed to create translation'); + this.error.set(this.transloco.translate('translation.list.errors.create')); } }); } @@ -128,29 +131,37 @@ export class TranslationComponent implements OnInit, OnDestroy { let confirmOptions: object; if (pages_to_translate === 0) { - message = 'All pages are already translated. Nothing will be billed.'; - confirmOptions = { confirmText: 'OK', cancelText: 'Cancel' }; + message = this.transloco.translate('translation.list.confirm.noPagesMessage'); + confirmOptions = { + confirmText: this.transloco.translate('translation.list.confirm.ok'), + cancelText: this.transloco.translate('translation.list.confirm.cancel'), + }; } else { const pricing = this.translationService.pricingRules(); const totalCostStr = pricing ? estimateTranslationCost(total_chars, pricing) : '~'; const avgCostStr = pricing && pages_to_translate > 0 ? estimateTranslationCost(Math.round(total_chars / pages_to_translate), pricing) : '~'; - const pageLabel = pages_to_translate === 1 ? '1 page' : `${pages_to_translate} pages`; - const costLine = `Estimated cost: ~${totalCostStr} (~${avgCostStr}/page on average). Canceling mid-run won't bill you for the full amount.`; - message = `${pageLabel} will be ${override ? 're-' : ''}translated. ${costLine}`; + const pageLabel = pages_to_translate === 1 + ? this.transloco.translate('translation.list.confirm.pageLabel') + : this.transloco.translate('translation.list.confirm.pageLabelPlural', { count: pages_to_translate }); + const costLine = this.transloco.translate('translation.list.confirm.costLine', { total: totalCostStr, avg: avgCostStr }); + message = this.transloco.translate( + override ? 'translation.list.confirm.messageReTranslate' : 'translation.list.confirm.message', + { pageLabel, costLine } + ); confirmOptions = override ? { - warningText: 'All existing translations for this document and language will be replaced.', + warningText: this.transloco.translate('translation.list.confirm.overrideWarning'), isDanger: true, - confirmText: 'Re-translate', - } : { confirmText: 'Translate' }; + confirmText: this.transloco.translate('translation.list.confirm.reTranslate'), + } : { confirmText: this.transloco.translate('translation.list.confirm.translate') }; } const confirmed = await this.confirmationService.confirm({ - title: override ? 'Override Re-translate All?' : 'Translate All?', + title: this.transloco.translate(override ? 'translation.list.confirm.titleOverride' : 'translation.list.confirm.title'), message, ...confirmOptions, - cancelText: 'Cancel', + cancelText: this.transloco.translate('translation.list.confirm.cancel'), } as any); if (!confirmed) return; @@ -170,13 +181,13 @@ export class TranslationComponent implements OnInit, OnDestroy { this.translations.update(list => list.map(item => item.id === t.id ? { ...item, status: t.status } : item) ); - this.error.set('Failed to start translation'); + this.error.set(this.transloco.translate('translation.list.errors.start')); } }); }, error: () => { this.estimatingIds.update(s => { const n = new Set(s); n.delete(t.id); return n; }); - this.error.set('Failed to fetch translation estimate'); + this.error.set(this.transloco.translate('translation.list.errors.estimate')); } }); } @@ -187,7 +198,7 @@ export class TranslationComponent implements OnInit, OnDestroy { this.translationService.cancelTranslation(t.document_id, t.id).subscribe({ next: () => this.loadTranslations(this.currentPage()), - error: () => this.error.set('Failed to cancel translation') + error: () => this.error.set(this.transloco.translate('translation.list.errors.cancel')) }); } @@ -195,19 +206,22 @@ export class TranslationComponent implements OnInit, OnDestroy { event.preventDefault(); event.stopPropagation(); - const name = `${t.document?.effective_filename ?? 'document'} (${this.getLanguageName(t.target_language)})`; + const name = this.transloco.translate('translation.list.confirm.deleteName', { + filename: t.document?.effective_filename ?? this.transloco.translate('translation.list.confirm.documentFallback'), + language: this.getLanguageName(t.target_language), + }); const confirmed = await this.confirmationService.confirm({ - title: 'Delete Translation', - message: `Are you sure you want to delete the translation "${name}"? This cannot be undone.`, - confirmText: 'Delete', - cancelText: 'Cancel', + title: this.transloco.translate('translation.list.confirm.deleteTitle'), + message: this.transloco.translate('translation.list.confirm.deleteMessage', { name }), + confirmText: this.transloco.translate('translation.list.confirm.delete'), + cancelText: this.transloco.translate('translation.list.confirm.cancel'), isDanger: true }); if (!confirmed) return; this.translationService.deleteTranslation(t.document_id, t.id).subscribe({ next: () => this.loadTranslations(this.currentPage()), - error: () => this.error.set('Failed to delete translation') + error: () => this.error.set(this.transloco.translate('translation.list.errors.delete')) }); } diff --git a/wordkeep-client/src/app/transloco/transloco-testing.ts b/wordkeep-client/src/app/transloco/transloco-testing.ts index 4c1f3c7..e001016 100644 --- a/wordkeep-client/src/app/transloco/transloco-testing.ts +++ b/wordkeep-client/src/app/transloco/transloco-testing.ts @@ -10,6 +10,22 @@ import authEn from '../../../public/assets/i18n/auth/en.json'; import authRo from '../../../public/assets/i18n/auth/ro.json'; import sharedEn from '../../../public/assets/i18n/shared/en.json'; import sharedRo from '../../../public/assets/i18n/shared/ro.json'; +import documentsEn from '../../../public/assets/i18n/documents/en.json'; +import documentsRo from '../../../public/assets/i18n/documents/ro.json'; +import extractionEn from '../../../public/assets/i18n/extraction/en.json'; +import extractionRo from '../../../public/assets/i18n/extraction/ro.json'; +import pageLinkingEn from '../../../public/assets/i18n/pageLinking/en.json'; +import pageLinkingRo from '../../../public/assets/i18n/pageLinking/ro.json'; +import bookViewEn from '../../../public/assets/i18n/bookView/en.json'; +import bookViewRo from '../../../public/assets/i18n/bookView/ro.json'; +import translationEn from '../../../public/assets/i18n/translation/en.json'; +import translationRo from '../../../public/assets/i18n/translation/ro.json'; +import dictionaryEn from '../../../public/assets/i18n/dictionary/en.json'; +import dictionaryRo from '../../../public/assets/i18n/dictionary/ro.json'; +import shellEn from '../../../public/assets/i18n/shell/en.json'; +import shellRo from '../../../public/assets/i18n/shell/ro.json'; +import homeEn from '../../../public/assets/i18n/home/en.json'; +import homeRo from '../../../public/assets/i18n/home/ro.json'; /** * Transloco module preconfigured for unit tests. Add it to a TestBed's @@ -29,6 +45,22 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'auth/ro': authRo, 'shared/en': sharedEn, 'shared/ro': sharedRo, + 'documents/en': documentsEn, + 'documents/ro': documentsRo, + 'extraction/en': extractionEn, + 'extraction/ro': extractionRo, + 'pageLinking/en': pageLinkingEn, + 'pageLinking/ro': pageLinkingRo, + 'bookView/en': bookViewEn, + 'bookView/ro': bookViewRo, + 'translation/en': translationEn, + 'translation/ro': translationRo, + 'dictionary/en': dictionaryEn, + 'dictionary/ro': dictionaryRo, + 'shell/en': shellEn, + 'shell/ro': shellRo, + 'home/en': homeEn, + 'home/ro': homeRo, }, translocoConfig: { availableLangs: ['en', 'ro'], From 1ad294a8dd1256dbb1cbfa178c15089f92ca13fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 2 Jun 2026 16:57:09 +0300 Subject: [PATCH 06/17] [WKP-36] Localize genres + language names, fix Romanian plurals (ICU) Install/configure @jsverse/transloco-messageformat so ICU plural syntax works; the extraction scope already used it (was rendering literally). Convert the bookView documentCount/pageCount from the English {{plural}} suffix hack to proper ICU one/few/other Romanian forms. Add a global languages. map (EN + RO) and repoint every per-component language-name lookup (document-detail, book-view(+reader), translation(+reader), new-translation-dialog, recent-activity) at it via translate(), replacing the duplicated English maps. Translate document genres via the documents scope. All 1073 unit tests pass; i18n guard green (3 intentional templates allowlisted). Co-Authored-By: Claude Opus 4.8 (1M context) --- wordkeep-client/package-lock.json | 79 +++++++++++++++++++ wordkeep-client/package.json | 1 + .../public/assets/i18n/bookView/en.json | 4 +- .../public/assets/i18n/bookView/ro.json | 4 +- .../public/assets/i18n/documents/en.json | 11 +++ .../public/assets/i18n/documents/ro.json | 11 +++ wordkeep-client/public/assets/i18n/en.json | 10 +++ wordkeep-client/public/assets/i18n/ro.json | 10 +++ wordkeep-client/src/app/app.config.ts | 4 + .../new-translation-dialog.html | 2 +- .../new-translation-dialog.ts | 10 ++- .../recent-activity/recent-activity.ts | 5 +- .../book-view-reader/book-view-reader.html | 2 +- .../book-view-reader/book-view-reader.ts | 6 +- .../src/app/pages/book-view/book-view.html | 6 +- .../src/app/pages/book-view/book-view.ts | 4 +- .../document-detail/document-detail.html | 6 +- .../pages/document-detail/document-detail.ts | 52 +++--------- .../translation-reader/translation-reader.ts | 4 +- .../src/app/pages/translation/translation.ts | 5 +- 20 files changed, 175 insertions(+), 61 deletions(-) diff --git a/wordkeep-client/package-lock.json b/wordkeep-client/package-lock.json index e9382da..43c1420 100644 --- a/wordkeep-client/package-lock.json +++ b/wordkeep-client/package-lock.json @@ -15,6 +15,7 @@ "@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", @@ -2092,6 +2093,22 @@ "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", @@ -2226,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", @@ -6443,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", @@ -6704,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", @@ -7909,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", diff --git a/wordkeep-client/package.json b/wordkeep-client/package.json index a4b3752..a5d5bfb 100644 --- a/wordkeep-client/package.json +++ b/wordkeep-client/package.json @@ -32,6 +32,7 @@ "@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", diff --git a/wordkeep-client/public/assets/i18n/bookView/en.json b/wordkeep-client/public/assets/i18n/bookView/en.json index dac04e2..24e3778 100644 --- a/wordkeep-client/public/assets/i18n/bookView/en.json +++ b/wordkeep-client/public/assets/i18n/bookView/en.json @@ -5,13 +5,13 @@ "loadError": "Failed to load documents", "exportError": "Failed to export document", "yourDocuments": "Your Documents", - "documentCount": "{{count}} document{{plural}}", + "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}} page{{plural}}", + "pageCount": "{count, plural, one {# page} other {# pages}}", "extracted": "{{completed}}/{{total}} extracted ({{percent}}%)", "linked": "{{completed}}/{{total}} linked ({{percent}}%)", "detecting": "Detecting...", diff --git a/wordkeep-client/public/assets/i18n/bookView/ro.json b/wordkeep-client/public/assets/i18n/bookView/ro.json index 93c866d..276b305 100644 --- a/wordkeep-client/public/assets/i18n/bookView/ro.json +++ b/wordkeep-client/public/assets/i18n/bookView/ro.json @@ -5,13 +5,13 @@ "loadError": "Încărcarea documentelor a eșuat", "exportError": "Exportul documentului a eșuat", "yourDocuments": "Documentele tale", - "documentCount": "{{count}} document{{plural}}", + "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}} pagin{{plural}}", + "pageCount": "{count, plural, one {# pagină} few {# pagini} other {# de pagini}}", "extracted": "{{completed}}/{{total}} extrase ({{percent}}%)", "linked": "{{completed}}/{{total}} legate ({{percent}}%)", "detecting": "Se detectează...", diff --git a/wordkeep-client/public/assets/i18n/documents/en.json b/wordkeep-client/public/assets/i18n/documents/en.json index 351ee81..e3d1122 100644 --- a/wordkeep-client/public/assets/i18n/documents/en.json +++ b/wordkeep-client/public/assets/i18n/documents/en.json @@ -43,6 +43,17 @@ }, "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" diff --git a/wordkeep-client/public/assets/i18n/documents/ro.json b/wordkeep-client/public/assets/i18n/documents/ro.json index 3ee9852..8d5275e 100644 --- a/wordkeep-client/public/assets/i18n/documents/ro.json +++ b/wordkeep-client/public/assets/i18n/documents/ro.json @@ -43,6 +43,17 @@ }, "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" diff --git a/wordkeep-client/public/assets/i18n/en.json b/wordkeep-client/public/assets/i18n/en.json index 1eeb222..570bddc 100644 --- a/wordkeep-client/public/assets/i18n/en.json +++ b/wordkeep-client/public/assets/i18n/en.json @@ -21,5 +21,15 @@ "delete": "Delete", "edit": "Edit", "loading": "Loading…" + }, + "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/ro.json b/wordkeep-client/public/assets/i18n/ro.json index 4bf8ea9..03b4b0c 100644 --- a/wordkeep-client/public/assets/i18n/ro.json +++ b/wordkeep-client/public/assets/i18n/ro.json @@ -21,5 +21,15 @@ "delete": "Șterge", "edit": "Editează", "loading": "Se încarcă…" + }, + "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/src/app/app.config.ts b/wordkeep-client/src/app/app.config.ts index 8199f96..a62c593 100644 --- a/wordkeep-client/src/app/app.config.ts +++ b/wordkeep-client/src/app/app.config.ts @@ -12,6 +12,7 @@ import { registerLocaleData } from '@angular/common'; import localeRo from '@angular/common/locales/ro'; import localeEnGb from '@angular/common/locales/en-GB'; import { provideTransloco, TranslocoService } from '@jsverse/transloco'; +import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat'; import { routes } from './app.routes'; import { authInterceptor } from './interceptors/auth.interceptor'; @@ -39,6 +40,9 @@ export const appConfig: ApplicationConfig = { }, loader: TranslocoHttpLoader, }), + // ICU MessageFormat transpiler — enables {count, plural, one {…} few {…} + // other {…}} syntax, needed for correct Romanian plural forms. + provideTranslocoMessageformat(), // Bridge the persisted locale signal to Transloco's active language. Kept // here (not in UserPreferencesService) so the service stays free of a // Transloco dependency — otherwise every spec touching preferences would diff --git a/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.html b/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.html index 8d94abe..8687013 100644 --- a/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.html +++ b/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.html @@ -38,7 +38,7 @@

{{ t('newTranslation.title') }}

diff --git a/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.ts b/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.ts index d9aedd6..4c06bfb 100644 --- a/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.ts +++ b/wordkeep-client/src/app/components/new-translation-dialog/new-translation-dialog.ts @@ -1,7 +1,7 @@ import { Component, signal, HostListener, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService } from '../../services/document.service'; import { Document } from '../../models/document.model'; @@ -58,6 +58,7 @@ const LANGUAGES = [ }) export class NewTranslationDialogComponent { private documentService = inject(DocumentService); + private transloco = inject(TranslocoService); visible = signal(false); closing = signal(false); @@ -68,6 +69,13 @@ export class NewTranslationDialogComponent { selectedLanguage = signal(''); languages = LANGUAGES; + /** Translated language name for display; falls back to the code. */ + getLanguageName(code: string): string { + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; + } + private resolvePromise: ((result: NewTranslationResult | null) => void) | null = null; @HostListener('document:keydown', ['$event']) diff --git a/wordkeep-client/src/app/components/recent-activity/recent-activity.ts b/wordkeep-client/src/app/components/recent-activity/recent-activity.ts index d6016e9..6694f00 100644 --- a/wordkeep-client/src/app/components/recent-activity/recent-activity.ts +++ b/wordkeep-client/src/app/components/recent-activity/recent-activity.ts @@ -5,7 +5,6 @@ import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@js import { WorkStatusService } from '../../services/work-status.service'; import { UserPreferencesService } from '../../services/user-preferences.service'; import { RecentActivityItem } from '../../models/document.model'; -import { languageDisplayName } from '../../utils/language-names'; import { formatNumericDate } from '../../utils/date-format'; interface BadgeMeta { @@ -51,7 +50,9 @@ export class RecentActivityComponent implements OnInit { if (type === 'book_view') return { label: t('bookView'), cssClass: 'badge-book-view' }; if (type.startsWith('translation_')) { const lang = type.slice('translation_'.length); - return { label: t('translation', { lang: languageDisplayName(lang) }), cssClass: 'badge-translation' }; + const langKey = `languages.${lang}`; + const langName = this.transloco.translate(langKey); + return { label: t('translation', { lang: langName === langKey ? lang : langName }), cssClass: 'badge-translation' }; } return { label: type, cssClass: 'badge-generic' }; } diff --git a/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.html b/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.html index 4e7f1ca..a43a294 100644 --- a/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.html +++ b/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.html @@ -31,7 +31,7 @@

{{ doc.effective_filename }}

} @else { } diff --git a/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.ts b/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.ts index b992fde..109c2b3 100644 --- a/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.ts +++ b/wordkeep-client/src/app/pages/book-view-reader/book-view-reader.ts @@ -230,7 +230,7 @@ export class BookViewReaderComponent implements OnInit { const confirmed = await this.confirmationService.confirm({ title: this.transloco.translate('bookView.reader.incompleteTranslationTitle'), message: this.transloco.translate('bookView.reader.incompleteTranslationMessage', { - language: LANGUAGES[lang] ?? lang, + language: this.getLanguageName(lang), completed: t.pages_completed, total: t.pages_total, }), @@ -405,6 +405,8 @@ export class BookViewReaderComponent implements OnInit { getLanguageName(code: string | null): string { if (!code) return this.transloco.translate('bookView.reader.noLanguageSet'); - return LANGUAGES[code] ?? code; + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; } } diff --git a/wordkeep-client/src/app/pages/book-view/book-view.html b/wordkeep-client/src/app/pages/book-view/book-view.html index 7038637..52621fb 100644 --- a/wordkeep-client/src/app/pages/book-view/book-view.html +++ b/wordkeep-client/src/app/pages/book-view/book-view.html @@ -28,7 +28,7 @@

{{ t('list.title') }}

{{ t('list.yourDocuments') }}

@if (total() > 0) { - {{ t('list.documentCount', { count: total(), plural: total() === 1 ? '' : 's' }) }} + {{ t('list.documentCount', { count: total() }) }} }
@@ -67,7 +67,7 @@

{{ doc.effective_filename }}

}
- {{ t('list.pageCount', { count: doc.page_count, plural: doc.page_count === 1 ? '' : 's' }) }} + {{ t('list.pageCount', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }} @@ -97,7 +97,7 @@

{{ doc.effective_filename }}

@if (getAvailableLanguages(doc).length > 0) { } @else if (doc.language_detection_status === 'pending' || doc.language_detection_status === 'processing') { diff --git a/wordkeep-client/src/app/pages/book-view/book-view.ts b/wordkeep-client/src/app/pages/book-view/book-view.ts index 5c2e154..8fa8ac1 100644 --- a/wordkeep-client/src/app/pages/book-view/book-view.ts +++ b/wordkeep-client/src/app/pages/book-view/book-view.ts @@ -112,7 +112,9 @@ export class BookViewComponent implements OnInit, OnDestroy { getLanguageName(code: string | null): string { if (!code) return this.transloco.translate('bookView.list.noLanguageSet'); - return LANGUAGES[code] ?? code; + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; } getSelectedLanguage(doc: Document): string { diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.html b/wordkeep-client/src/app/pages/document-detail/document-detail.html index 60038be..2350ccb 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.html +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.html @@ -149,7 +149,7 @@

{{ t('detail.title') }}

@@ -159,7 +159,7 @@

{{ t('detail.title') }}

@@ -209,7 +209,7 @@

{{ doc.effective_filename }}

@if (doc.genre) { } diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.ts b/wordkeep-client/src/app/pages/document-detail/document-detail.ts index 1243a9f..563e8aa 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.ts +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.ts @@ -393,45 +393,17 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { } getLanguageDisplayName(code: string | null): string { - if (!code) return 'Not detected'; - const languages: Record = { - 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' - }; - return languages[code] || code.toUpperCase(); + if (!code) return this.transloco.translate('documents.detail.notDetected'); + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code.toUpperCase() : name; + } + + /** Translated label for a stored (English) genre value; falls back to the value. */ + genreLabel(value: string): string { + if (!value) return ''; + const key = `documents.detail.genres.${value}`; + const label = this.transloco.translate(key); + return label === key ? value : label; } } diff --git a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts index 6dc7f03..0120249 100644 --- a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts +++ b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts @@ -820,7 +820,9 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { getLanguageName(code: string | null): string { if (!code) return this.transloco.translate('translation.reader.originalLabel'); - return LANGUAGE_NAMES[code] ?? code; + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; } closeFootnoteTooltip() { diff --git a/wordkeep-client/src/app/pages/translation/translation.ts b/wordkeep-client/src/app/pages/translation/translation.ts index c2e1e5d..273d10f 100644 --- a/wordkeep-client/src/app/pages/translation/translation.ts +++ b/wordkeep-client/src/app/pages/translation/translation.ts @@ -10,7 +10,6 @@ import { DocumentTranslation, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; import { estimateTranslationCost } from '../../utils/translation-cost'; -import { languageDisplayName } from '../../utils/language-names'; import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ @@ -247,7 +246,9 @@ export class TranslationComponent implements OnInit, OnDestroy { } getLanguageName(code: string): string { - return languageDisplayName(code); + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; } formatDate(dateString: string): string { From 5f3e828beb2b173791263c318e934a1613ebbbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 2 Jun 2026 16:58:03 +0300 Subject: [PATCH 07/17] [WKP-36] Document i18n plural (ICU) + global language-map conventions Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/claude-code-memories/i18n-transloco-architecture.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/claude-code-memories/i18n-transloco-architecture.md b/docs/claude-code-memories/i18n-transloco-architecture.md index bb98ab9..003b7aa 100644 --- a/docs/claude-code-memories/i18n-transloco-architecture.md +++ b/docs/claude-code-memories/i18n-transloco-architecture.md @@ -15,4 +15,8 @@ Localization (WKP-36) uses **@jsverse/transloco** (frontend) + Laravel `lang/` ( 4. **Spec setup:** any component using the `transloco` pipe/`*transloco` directive needs `getTranslocoTestingModule()` (from `src/app/transloco/transloco-testing.ts`) in its TestBed `imports`; register every new scope's JSON there. Run unit tests with [[frontend-tests-use-npm-test]]. +5. **Plurals use ICU + MessageFormat.** `@jsverse/transloco-messageformat` is installed and `provideTranslocoMessageformat()` is in `app.config.ts`. Write counts as `{count, plural, one {# x} few {# y} other {# de z}}` — Romanian needs the `few` form (2–19) and `de` for 20+. Do NOT use an English `{{plural}}` `''`/`'s'` suffix. (The testing module can't register the transpiler via `forRoot`, so ICU renders literally in unit tests — fine unless a spec asserts plural text, in which case add `provideTranslocoMessageformat()` to that spec's providers.) + +6. **Language names live in the GLOBAL `languages.` map** (`public/assets/i18n/{en,ro}.json`). Resolve them with `this.transloco.translate('languages.' + code)` (programmatic `translate()` reads the root, unaffected by a component's ambient scope), falling back to the code when the returned value equals the key. Don't reintroduce per-component English language maps. + See [[numeric-date-format]] for locale-aware dates/numbers (`en-GB`/`ro` registered in `app.config.ts`). From e6a5b75e8d42dbd8bb3df00b43ec6f27f42a16bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Fri, 5 Jun 2026 17:39:27 +0300 Subject: [PATCH 08/17] [WKP-36] Localize transactional emails, API messages, validation fields + personalisation options - Stamp request locale on all queued mailables (verify/reset/password-changed/email-change/2FA) - Add lang/{en,ro}/messages.php; replace hardcoded controller literals with __() (full sweep, 9 controllers) - Populate validation attributes array (RO field names) so :attribute localizes - Localize layout + palette label/blurb via transloco (profile/{en,ro}.json + template) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Http/Controllers/Api/AuthController.php | 34 +++---- .../Controllers/Api/BulkOcrController.php | 10 +-- .../Controllers/Api/DocumentController.php | 2 +- .../Controllers/Api/FootnoteController.php | 2 +- app/Http/Controllers/Api/OcrController.php | 8 +- app/Http/Controllers/Api/PageController.php | 2 +- .../Controllers/Api/PageLinkingController.php | 6 +- .../Controllers/Api/ProfileController.php | 40 ++++----- .../Controllers/Api/TranslationController.php | 34 +++---- .../i18n-transloco-architecture.md | 4 + lang/en/messages.php | 89 +++++++++++++++++++ lang/en/validation.php | 10 ++- lang/ro/messages.php | 89 +++++++++++++++++++ lang/ro/validation.php | 10 ++- .../public/assets/i18n/profile/en.json | 16 ++++ .../public/assets/i18n/profile/ro.json | 16 ++++ .../profile-personalisation.html | 12 +-- 17 files changed, 307 insertions(+), 77 deletions(-) create mode 100644 lang/en/messages.php create mode 100644 lang/ro/messages.php diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index ae4f595..6696341 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -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); } @@ -74,7 +74,7 @@ public function login(Request $request): JsonResponse 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); } @@ -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(); @@ -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); } @@ -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); } @@ -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, ]); @@ -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'), ]); } @@ -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'), ]); } @@ -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); } @@ -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); } @@ -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, ]); @@ -307,7 +307,7 @@ public function logout(Request $request): JsonResponse } return response()->json([ - 'message' => 'Successfully logged out', + 'message' => __('messages.auth.logged_out'), ]); } diff --git a/app/Http/Controllers/Api/BulkOcrController.php b/app/Http/Controllers/Api/BulkOcrController.php index e18dee3..df1eb17 100644 --- a/app/Http/Controllers/Api/BulkOcrController.php +++ b/app/Http/Controllers/Api/BulkOcrController.php @@ -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); } @@ -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'), ]); } @@ -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); } @@ -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'), ]); } } diff --git a/app/Http/Controllers/Api/DocumentController.php b/app/Http/Controllers/Api/DocumentController.php index 1bd012b..2f40352 100644 --- a/app/Http/Controllers/Api/DocumentController.php +++ b/app/Http/Controllers/Api/DocumentController.php @@ -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); diff --git a/app/Http/Controllers/Api/FootnoteController.php b/app/Http/Controllers/Api/FootnoteController.php index 26dedbd..8850b0e 100644 --- a/app/Http/Controllers/Api/FootnoteController.php +++ b/app/Http/Controllers/Api/FootnoteController.php @@ -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); diff --git a/app/Http/Controllers/Api/OcrController.php b/app/Http/Controllers/Api/OcrController.php index f3a2204..d2e73dc 100644 --- a/app/Http/Controllers/Api/OcrController.php +++ b/app/Http/Controllers/Api/OcrController.php @@ -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); } @@ -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); } } @@ -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); } } @@ -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']; diff --git a/app/Http/Controllers/Api/PageController.php b/app/Http/Controllers/Api/PageController.php index 0d3d6b0..639c353 100644 --- a/app/Http/Controllers/Api/PageController.php +++ b/app/Http/Controllers/Api/PageController.php @@ -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); } diff --git a/app/Http/Controllers/Api/PageLinkingController.php b/app/Http/Controllers/Api/PageLinkingController.php index 617006d..f2ba8bf 100644 --- a/app/Http/Controllers/Api/PageLinkingController.php +++ b/app/Http/Controllers/Api/PageLinkingController.php @@ -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]); @@ -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) { diff --git a/app/Http/Controllers/Api/ProfileController.php b/app/Http/Controllers/Api/ProfileController.php index 47a5684..11bdb8c 100644 --- a/app/Http/Controllers/Api/ProfileController.php +++ b/app/Http/Controllers/Api/ProfileController.php @@ -62,11 +62,11 @@ public function verifyPassword(Request $request): JsonResponse if (!Hash::check($request->password, $request->user()->password)) { return response()->json([ - 'errors' => ['password' => ['The password is incorrect.']], + 'errors' => ['password' => [__('validation.current_password')]], ], 422); } - return response()->json(['message' => 'Password verified.']); + return response()->json(['message' => __('messages.profile.password_verified')]); } /** @@ -91,17 +91,17 @@ public function requestEmailChange(Request $request): JsonResponse if (!Hash::check($request->password, $user->password)) { return response()->json([ - 'errors' => ['password' => ['The password is incorrect.']], + 'errors' => ['password' => [__('validation.current_password')]], ], 422); } $token = $user->generateEmailChangeToken($request->new_email); $verificationUrl = config('app.frontend_url') . '/verify-email-change?token=' . $token; - Mail::to($request->new_email)->queue(new VerifyEmailChange($user, $verificationUrl)); + Mail::to($request->new_email)->locale(app()->getLocale())->queue(new VerifyEmailChange($user, $verificationUrl)); return response()->json([ - 'message' => 'Verification email sent to ' . $request->new_email . '.', + 'message' => __('messages.profile.email_change_sent', ['email' => $request->new_email]), ]); } @@ -128,15 +128,15 @@ public function resendEmailChangeVerification(Request $request): JsonResponse $user = $request->user(); if (!$user->hasPendingEmailChange()) { - return response()->json(['message' => 'No pending email change.'], 400); + return response()->json(['message' => __('messages.profile.no_pending_email_change')], 400); } $token = $user->generateEmailChangeToken($user->pending_email); $verificationUrl = config('app.frontend_url') . '/verify-email-change?token=' . $token; - Mail::to($user->pending_email)->queue(new VerifyEmailChange($user, $verificationUrl)); + Mail::to($user->pending_email)->locale(app()->getLocale())->queue(new VerifyEmailChange($user, $verificationUrl)); - return response()->json(['message' => 'Verification email resent.']); + return response()->json(['message' => __('messages.profile.verification_resent')]); } /** @@ -198,7 +198,7 @@ public function verifyEmailChange(Request $request): JsonResponse if (!$user) { return response()->json([ 'error' => 'invalid_token', - 'message' => 'Invalid or expired token.', + 'message' => __('messages.profile.email_change_token_invalid'), ], 400); } @@ -206,14 +206,14 @@ public function verifyEmailChange(Request $request): JsonResponse $user->cancelEmailChange(); return response()->json([ 'error' => 'token_expired', - 'message' => 'This link has expired. Please request a new email change.', + 'message' => __('messages.profile.email_change_expired'), ], 400); } $user->confirmEmailChange(); return response()->json([ - 'message' => 'Email updated successfully.', + 'message' => __('messages.profile.email_updated'), 'user' => $this->userPayload($user->fresh()), ]); } @@ -234,14 +234,14 @@ public function changePassword(Request $request): JsonResponse if (!Hash::check($request->current_password, $user->password)) { return response()->json([ - 'errors' => ['current_password' => ['The password is incorrect.']], + 'errors' => ['current_password' => [__('validation.current_password')]], ], 422); } $user->update(['password' => Hash::make($request->new_password)]); - Mail::to($user->email)->queue(new PasswordChanged($user)); + Mail::to($user)->locale(app()->getLocale())->queue(new PasswordChanged($user)); - return response()->json(['message' => 'Password updated.']); + return response()->json(['message' => __('messages.profile.password_updated')]); } /** @@ -257,7 +257,7 @@ public function setupTwoFactor(Request $request): JsonResponse if (!Hash::check($request->password, $user->password)) { return response()->json([ - 'errors' => ['password' => ['The password is incorrect.']], + 'errors' => ['password' => [__('validation.current_password')]], ], 422); } @@ -293,19 +293,19 @@ public function confirmTwoFactor(Request $request): JsonResponse $user = $request->user(); if (!$user->two_factor_secret) { - return response()->json(['message' => 'Two-factor setup not initiated.'], 400); + return response()->json(['message' => __('messages.profile.two_factor_not_initiated')], 400); } $google2fa = new Google2FA(); if (!$google2fa->verifyKey($user->two_factor_secret, $request->code)) { return response()->json([ - 'errors' => ['code' => ['Invalid verification code.']], + 'errors' => ['code' => [__('messages.profile.two_factor_code_invalid')]], ], 422); } $recoveryCodes = $user->generateRecoveryCodes(); $user->update(['two_factor_confirmed_at' => now()]); - Mail::to($user->email)->queue(new TwoFactorEnabled($user)); + Mail::to($user)->locale(app()->getLocale())->queue(new TwoFactorEnabled($user)); return response()->json([ 'user' => $this->userPayload($user->fresh()), @@ -326,7 +326,7 @@ public function disableTwoFactor(Request $request): JsonResponse if (!Hash::check($request->password, $user->password)) { return response()->json([ - 'errors' => ['password' => ['The password is incorrect.']], + 'errors' => ['password' => [__('validation.current_password')]], ], 422); } @@ -336,7 +336,7 @@ public function disableTwoFactor(Request $request): JsonResponse 'two_factor_confirmed_at' => null, ]); - Mail::to($user->email)->queue(new TwoFactorDisabled($user)); + Mail::to($user)->locale(app()->getLocale())->queue(new TwoFactorDisabled($user)); return response()->json($this->userPayload($user->fresh())); } diff --git a/app/Http/Controllers/Api/TranslationController.php b/app/Http/Controllers/Api/TranslationController.php index 8ffa09f..3f840c2 100644 --- a/app/Http/Controllers/Api/TranslationController.php +++ b/app/Http/Controllers/Api/TranslationController.php @@ -95,13 +95,13 @@ public function translatePage(TranslatePageRequest $request, Document $document, $documentPage = $document->pages()->where('page_number', $page)->first(); if (!$documentPage) { - return response()->json(['error' => 'page_not_found', 'message' => "Page {$page} not found."], 404); + return response()->json(['error' => 'page_not_found', 'message' => __('messages.pages.not_found', ['page' => $page])], 404); } if (empty(trim($documentPage->ocr_text ?? ''))) { return response()->json([ 'error' => 'no_ocr_text', - 'message' => 'Page has no OCR text to translate.', + 'message' => __('messages.translation.page_no_ocr'), ], 422); } @@ -157,7 +157,7 @@ public function translatePage(TranslatePageRequest $request, Document $document, } catch (\Exception $e) { return response()->json([ 'error' => 'footnote_translation_failed', - 'message' => "Page translated, but footnote [^{$footnote->number}] failed: {$e->getMessage()}", + 'message' => __('messages.translation.footnote_failed', ['number' => $footnote->number, 'error' => $e->getMessage()]), 'data' => new DocumentPageTranslationResource($result), 'meta' => [ 'page_linking_incomplete' => $pageLinkingIncomplete, @@ -193,7 +193,7 @@ public function showPageTranslation(Document $document, int $page, string $langu $documentPage = $document->pages()->where('page_number', $page)->first(); if (!$documentPage) { - return response()->json(['error' => 'page_not_found', 'message' => "Page {$page} not found."], 404); + return response()->json(['error' => 'page_not_found', 'message' => __('messages.pages.not_found', ['page' => $page])], 404); } $translation = DocumentPageTranslation::where([ @@ -202,7 +202,7 @@ public function showPageTranslation(Document $document, int $page, string $langu ])->first(); if (!$translation) { - return response()->json(['error' => 'not_found', 'message' => 'No translation found for this page and language.'], 404); + return response()->json(['error' => 'not_found', 'message' => __('messages.translation.not_found_for_page')], 404); } return (new DocumentPageTranslationResource($translation))->response(); @@ -222,7 +222,7 @@ public function store(StartBulkTranslationRequest $request, Document $document): if (!$document->isReady()) { return response()->json([ 'error' => 'document_not_ready', - 'message' => 'Document is not ready.', + 'message' => __('messages.translation.document_not_ready'), ], 400); } @@ -236,7 +236,7 @@ public function store(StartBulkTranslationRequest $request, Document $document): if ($existing && $existing->isProcessing()) { return response()->json([ 'error' => 'already_running', - 'message' => 'A translation job for this language is already running.', + 'message' => __('messages.translation.job_already_running'), ], 400); } @@ -265,7 +265,7 @@ public function run(Request $request, Document $document, DocumentTranslation $t if ($translation->isProcessing()) { return response()->json([ 'error' => 'already_running', - 'message' => 'A translation job for this language is already running.', + 'message' => __('messages.translation.job_already_running'), ], 400); } @@ -298,7 +298,7 @@ public function cancel(Document $document, DocumentTranslation $translation): Js if (!$translation->isProcessing()) { return response()->json([ 'error' => 'not_running', - 'message' => 'No running translation job to cancel.', + 'message' => __('messages.translation.no_running_job'), ], 400); } @@ -353,7 +353,7 @@ public function savePageTranslationSplits(Request $request, Document $document, $documentPage = $document->pages()->where('page_number', $page)->first(); if (!$documentPage) { - return response()->json(['error' => 'page_not_found', 'message' => "Page {$page} not found."], 404); + return response()->json(['error' => 'page_not_found', 'message' => __('messages.pages.not_found', ['page' => $page])], 404); } $validated = $request->validate([ @@ -393,7 +393,7 @@ public function updatePageTranslation(UpdatePageTranslationRequest $request, Doc $documentPage = $document->pages()->where('page_number', $page)->first(); if (!$documentPage) { - return response()->json(['error' => 'page_not_found', 'message' => "Page {$page} not found."], 404); + return response()->json(['error' => 'page_not_found', 'message' => __('messages.pages.not_found', ['page' => $page])], 404); } $validated = $request->validated(); @@ -430,7 +430,7 @@ public function deletePageTranslation(Document $document, int $page, string $lan $documentPage = $document->pages()->where('page_number', $page)->first(); if (!$documentPage) { - return response()->json(['error' => 'page_not_found', 'message' => "Page {$page} not found."], 404); + return response()->json(['error' => 'page_not_found', 'message' => __('messages.pages.not_found', ['page' => $page])], 404); } DocumentPageTranslation::where([ @@ -440,7 +440,7 @@ public function deletePageTranslation(Document $document, int $page, string $lan $this->touchTranslationStatus($document, $page, $language); - return response()->json(['message' => 'Translation deleted.']); + return response()->json(['message' => __('messages.translation.deleted')]); } /** @@ -588,7 +588,7 @@ public function translateFootnoteItem(Request $request, Document $document, Docu } catch (\Exception $e) { return response()->json([ 'error' => 'translation_failed', - 'message' => "Footnote [^{$footnote->number}] failed to translate: {$e->getMessage()}", + 'message' => __('messages.translation.footnote_failed_short', ['number' => $footnote->number, 'error' => $e->getMessage()]), ], 422); } @@ -660,7 +660,7 @@ public function destroy(Document $document, DocumentTranslation $translation): J $translation->delete(); - return response()->json(['message' => 'Translation deleted.']); + return response()->json(['message' => __('messages.translation.deleted')]); } /** @@ -699,7 +699,7 @@ public function dismissWrongLanguage(Document $document, int $page, string $lang $documentPage = $document->pages()->where('page_number', $page)->first(); if (!$documentPage) { - return response()->json(['error' => 'Page not found'], 404); + return response()->json(['error' => __('messages.pages.not_found_plain')], 404); } $translation = DocumentPageTranslation::where([ @@ -708,7 +708,7 @@ public function dismissWrongLanguage(Document $document, int $page, string $lang ])->first(); if (!$translation) { - return response()->json(['error' => 'Translation not found'], 404); + return response()->json(['error' => __('messages.translation.not_found_plain')], 404); } $translation->update(['wrong_language' => false]); diff --git a/docs/claude-code-memories/i18n-transloco-architecture.md b/docs/claude-code-memories/i18n-transloco-architecture.md index 003b7aa..43b60eb 100644 --- a/docs/claude-code-memories/i18n-transloco-architecture.md +++ b/docs/claude-code-memories/i18n-transloco-architecture.md @@ -19,4 +19,8 @@ Localization (WKP-36) uses **@jsverse/transloco** (frontend) + Laravel `lang/` ( 6. **Language names live in the GLOBAL `languages.` map** (`public/assets/i18n/{en,ro}.json`). Resolve them with `this.transloco.translate('languages.' + code)` (programmatic `translate()` reads the root, unaffected by a component's ambient scope), falling back to the code when the returned value equals the key. Don't reintroduce per-component English language maps. +7. **Backend (Laravel) localization.** User-facing API messages live in `lang/{en,ro}/messages.php` (domain-grouped: `auth, profile, documents, pages, ocr, translation, page_linking, footnotes, bulk_ocr`); controllers return `__('messages..')`, never hardcoded literals. EN values are kept verbatim-identical to the old literals so PHPUnit assertions (which run under default `en`) stay green. Generic `['error' => 'Not found']` 404s and `app/Exceptions`+`app/Jobs` messages are intentionally NOT localized. Validation field names come from the `attributes` array in `lang/{en,ro}/validation.php`; wrong-password replies reuse `validation.current_password`. The frontend shows `err.error?.message ?? translate('')`, so any English `message` overrides the RO fallback — that's why backend messages must be localized. + +8. **Queued mail must stamp the locale.** Mailables are queued (`QUEUE_CONNECTION=database`); the worker has no request context (defaults to `en`), and `HasLocalePreference` is useless when mail is sent to a string address or before the prefs row exists (registration). Always queue with `Mail::to($x)->locale(app()->getLocale())->queue(...)` so the request locale (from the `Accept-Language` interceptor) is serialized onto the job. + See [[numeric-date-format]] for locale-aware dates/numbers (`en-GB`/`ro` registered in `app.config.ts`). diff --git a/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/validation.php b/lang/en/validation.php index d5efa9a..244a33f 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -197,6 +197,14 @@ | */ - 'attributes' => [], + '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/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/validation.php b/lang/ro/validation.php index 574c717..22e8db6 100644 --- a/lang/ro/validation.php +++ b/lang/ro/validation.php @@ -170,6 +170,14 @@ ], ], - 'attributes' => [], + '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/wordkeep-client/public/assets/i18n/profile/en.json b/wordkeep-client/public/assets/i18n/profile/en.json index 19955ae..a472418 100644 --- a/wordkeep-client/public/assets/i18n/profile/en.json +++ b/wordkeep-client/public/assets/i18n/profile/en.json @@ -17,6 +17,22 @@ "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.", diff --git a/wordkeep-client/public/assets/i18n/profile/ro.json b/wordkeep-client/public/assets/i18n/profile/ro.json index ccb28e2..3c4510d 100644 --- a/wordkeep-client/public/assets/i18n/profile/ro.json +++ b/wordkeep-client/public/assets/i18n/profile/ro.json @@ -17,6 +17,22 @@ "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.", diff --git a/wordkeep-client/src/app/pages/profile/personalisation/profile-personalisation.html b/wordkeep-client/src/app/pages/profile/personalisation/profile-personalisation.html index 692d056..f22863c 100644 --- a/wordkeep-client/src/app/pages/profile/personalisation/profile-personalisation.html +++ b/wordkeep-client/src/app/pages/profile/personalisation/profile-personalisation.html @@ -7,7 +7,7 @@

{{ t('personalisation.title') }}

{{ t('personalisation.layoutHeading') }}

- {{ layoutService.layout() }} + {{ t('personalisation.layouts.' + layoutService.layout() + '.label') }}
@@ -19,8 +19,8 @@

{{ t('personalisation.layoutHeading') }}

(click)="setLayout(l.id)" [attr.aria-pressed]="layoutService.layout() === l.id" > - {{ l.label }} - {{ l.blurb }} + {{ t('personalisation.layouts.' + l.id + '.label') }} + {{ t('personalisation.layouts.' + l.id + '.blurb') }} @if (layoutService.layout() === l.id) {

{{ t('personalisation.layoutHeading') }}

{{ t('personalisation.paletteHeading') }}

- {{ themeService.palette() }} + {{ t('personalisation.palettes.' + themeService.palette() + '.label') }}
@@ -52,8 +52,8 @@

{{ t('personalisation.paletteHeading') }}

- {{ p.label }} - {{ p.blurb }} + {{ t('personalisation.palettes.' + p.id + '.label') }} + {{ t('personalisation.palettes.' + p.id + '.blurb') }} @if (themeService.palette() === p.id) {

{{ t('renameDialog.title') }}

{{ t('renameDialog.restore') }} } -
- - +
}
diff --git a/wordkeep-client/src/app/components/rename-dialog/rename-dialog.scss b/wordkeep-client/src/app/components/rename-dialog/rename-dialog.scss index be0e594..1b153d8 100644 --- a/wordkeep-client/src/app/components/rename-dialog/rename-dialog.scss +++ b/wordkeep-client/src/app/components/rename-dialog/rename-dialog.scss @@ -28,7 +28,9 @@ background: var(--color-surface); border-radius: 12px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); - max-width: 420px; + // Wider than the other dialogs so the restore + cancel + save trio fits on a + // single row (the localized "Restaurează originalul" label is long). + max-width: 480px; width: 100%; animation: slideIn 0.15s ease-out; @@ -113,17 +115,24 @@ .dialog-footer { display: flex; + flex-wrap: wrap; align-items: center; + justify-content: flex-end; gap: 0.75rem; padding: 0 1.5rem 1.5rem; } -.spacer { - flex: 1; +// keep cancel + save together; restore is pushed left and drops to its own +// row when the long (localized) label leaves no room on a single line. +.dialog-footer__actions { + display: flex; + gap: 0.75rem; } .btn-restore { - padding: 0.625rem 1rem; + margin-right: auto; + padding: 0.625rem 1.25rem; + white-space: nowrap; background: var(--color-surface); border: 1.5px solid color-mix(in oklab, var(--color-warning) 35%, transparent); border-radius: 8px; @@ -146,6 +155,7 @@ .btn-cancel { padding: 0.625rem 1.25rem; + white-space: nowrap; background: var(--color-surface); border: 1.5px solid var(--color-rule); border-radius: 8px; @@ -169,6 +179,13 @@ .btn-confirm { padding: 0.625rem 1.25rem; + white-space: nowrap; + // Reset the global auth `.btn-primary` rules (uppercase / letter-spacing / + // top margin / full width) that leak in via the shared class name. + text-transform: none; + letter-spacing: normal; + margin-top: 0; + width: auto; border: none; border-radius: 8px; font-size: 0.875rem; diff --git a/wordkeep-client/src/app/services/confirmation.service.spec.ts b/wordkeep-client/src/app/services/confirmation.service.spec.ts index 8527595..3f08251 100644 --- a/wordkeep-client/src/app/services/confirmation.service.spec.ts +++ b/wordkeep-client/src/app/services/confirmation.service.spec.ts @@ -1,13 +1,24 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TranslocoService } from '@jsverse/transloco'; import { ConfirmationService } from './confirmation.service'; describe('ConfirmationService', () => { let service: ConfirmationService; + // Echo keys (with params appended) so assertions can verify the right + // translation key + interpolation reach the dialog config. + const translocoStub = { + translate: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key + }; + beforeEach(() => { TestBed.configureTestingModule({ - providers: [ConfirmationService] + providers: [ + ConfirmationService, + { provide: TranslocoService, useValue: translocoStub } + ] }); service = TestBed.inject(ConfirmationService); }); @@ -24,7 +35,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Delete Document' + title: 'confirmations.deleteDocument.title' }) ); }); @@ -48,7 +59,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - confirmText: 'Delete' + confirmText: 'confirmations.deleteDocument.confirm' }) ); }); @@ -90,7 +101,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Override OCR Processing' + title: 'confirmations.overrideOcrSingle.title' }) ); }); @@ -102,7 +113,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('this page') + message: 'confirmations.overrideOcrSingle.message' }) ); }); @@ -114,7 +125,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - warningText: expect.stringContaining('this page') + warningText: 'confirmations.overrideOcrSingle.warning' }) ); }); @@ -126,7 +137,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - confirmText: 'Run OCR' + confirmText: 'confirmations.overrideOcrSingle.confirm' }) ); }); @@ -168,7 +179,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Override OCR Processing' + title: 'confirmations.overrideOcrBulk.title' }) ); }); @@ -180,7 +191,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('all pages') + message: 'confirmations.overrideOcrBulk.message' }) ); }); @@ -192,7 +203,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - warningText: expect.stringContaining('all pages') + warningText: 'confirmations.overrideOcrBulk.warning' }) ); }); @@ -204,7 +215,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - confirmText: 'Run OCR' + confirmText: 'confirmations.overrideOcrBulk.confirm' }) ); }); @@ -238,6 +249,37 @@ describe('ConfirmationService', () => { }); }); + // Guard: every helper must feed the dialog translation keys, never raw + // English literals. Catches a regression the .html i18n check can't see + // (the strings originate in this service, not a template). + describe('localization guard', () => { + const helpers: Array<[string, () => Promise]> = [ + ['confirmDelete', () => service.confirmDelete('doc.pdf')], + ['confirmOverrideOcrSingle', () => service.confirmOverrideOcrSingle()], + ['confirmOverrideExtractTextSingle', () => service.confirmOverrideExtractTextSingle()], + ['confirmOverrideOcrBulk', () => service.confirmOverrideOcrBulk()] + ]; + + for (const [name, call] of helpers) { + it(`${name}_passes_only_translation_keys`, async () => { + const translateSpy = vi.spyOn(translocoStub, 'translate'); + const confirmSpy = vi.spyOn(service, 'confirm').mockResolvedValue(true); + + await call(); + + expect(translateSpy).toHaveBeenCalled(); + const cfg = confirmSpy.mock.calls[0][0]; + expect(cfg.title).toMatch(/^confirmations\./); + expect(cfg.confirmText).toMatch(/^confirmations\./); + expect(cfg.cancelText).toBe('common.cancel'); + if (cfg.message) expect(cfg.message).toMatch(/^confirmations\./); + if (cfg.warningText) expect(cfg.warningText).toMatch(/^confirmations\./); + + translateSpy.mockRestore(); + }); + } + }); + describe('confirm method structure', () => { it('should_return_promise', () => { // Mock to prevent actual DOM manipulation diff --git a/wordkeep-client/src/app/services/confirmation.service.ts b/wordkeep-client/src/app/services/confirmation.service.ts index d7ff6d9..096ad28 100644 --- a/wordkeep-client/src/app/services/confirmation.service.ts +++ b/wordkeep-client/src/app/services/confirmation.service.ts @@ -1,4 +1,5 @@ import { Injectable, ApplicationRef, createComponent, EnvironmentInjector, inject } from '@angular/core'; +import { TranslocoService } from '@jsverse/transloco'; import { ConfirmationDialogComponent, ConfirmationDialogConfig } from '../components/confirmation-dialog/confirmation-dialog'; @Injectable({ @@ -7,6 +8,7 @@ import { ConfirmationDialogComponent, ConfirmationDialogConfig } from '../compon export class ConfirmationService { private appRef = inject(ApplicationRef); private injector = inject(EnvironmentInjector); + private transloco = inject(TranslocoService); private dialogComponentRef: any = null; async confirm(config: ConfirmationDialogConfig): Promise { @@ -23,43 +25,43 @@ export class ConfirmationService { async confirmDelete(itemName: string): Promise { return this.confirm({ - title: 'Delete Document', - message: `Are you sure you want to delete "${itemName}"? This cannot be undone.`, - confirmText: 'Delete', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.deleteDocument.title'), + message: this.transloco.translate('confirmations.deleteDocument.message', { name: itemName }), + confirmText: this.transloco.translate('confirmations.deleteDocument.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } async confirmOverrideOcrSingle(): Promise { return this.confirm({ - title: 'Override OCR Processing', - message: 'This will re-process this page with OCR.', - warningText: 'Any manual text edits or formatting changes on this page will be lost.', - confirmText: 'Run OCR', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.overrideOcrSingle.title'), + message: this.transloco.translate('confirmations.overrideOcrSingle.message'), + warningText: this.transloco.translate('confirmations.overrideOcrSingle.warning'), + confirmText: this.transloco.translate('confirmations.overrideOcrSingle.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } async confirmOverrideExtractTextSingle(): Promise { return this.confirm({ - title: 'Re-extract Text', - message: 'This will re-extract text from this page.', - warningText: 'Any manual text edits or formatting changes on this page will be lost.', - confirmText: 'Extract Text', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.overrideExtractTextSingle.title'), + message: this.transloco.translate('confirmations.overrideExtractTextSingle.message'), + warningText: this.transloco.translate('confirmations.overrideExtractTextSingle.warning'), + confirmText: this.transloco.translate('confirmations.overrideExtractTextSingle.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } async confirmOverrideOcrBulk(): Promise { return this.confirm({ - title: 'Override OCR Processing', - message: 'This will re-process all pages with OCR.', - warningText: 'Any manual text edits or formatting changes on all pages will be lost.', - confirmText: 'Run OCR', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.overrideOcrBulk.title'), + message: this.transloco.translate('confirmations.overrideOcrBulk.message'), + warningText: this.transloco.translate('confirmations.overrideOcrBulk.warning'), + confirmText: this.transloco.translate('confirmations.overrideOcrBulk.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } From 19b95ec02dfcb10ca38d91dc964a0f359c44a344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 14:37:23 +0300 Subject: [PATCH 10/17] [WKP-36] Localize IGNORED page overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay hid its localized text (font-size:0) + painted a ::after with hardcoded content:'IGNORED'. Render t() in a .ignored-overlay__badge span (uppercase via CSS) so RO shows IGNORATĂ. extraction + page-linking readers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/app/pages/extraction-reader/extraction-reader.html | 2 +- .../src/app/pages/extraction-reader/extraction-reader.scss | 5 ++--- .../app/pages/page-linking-reader/page-linking-reader.html | 4 ++-- .../app/pages/page-linking-reader/page-linking-reader.scss | 5 ++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.html b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.html index 30bd68b..17c54be 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.html +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.html @@ -153,7 +153,7 @@

{{ t('pageHeader', { current: currentPage(), total: doc.page_count }) }}

} @if (isCurrentPageIgnored()) { -
{{ t('ignoredBadge') }}
+
{{ t('ignoredBadge') }}
} } @else if (loadingImage()) {
diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss index 972a429..38c415b 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss @@ -412,13 +412,12 @@ justify-content: center; pointer-events: none; border-radius: 4px; - font-size: 0; - &::after { - content: 'IGNORED'; + &__badge { font-size: 0.875rem; font-weight: 700; letter-spacing: 0.1em; + text-transform: uppercase; padding: 0.25rem 0.75rem; background: var(--color-fg-muted); color: var(--color-surface-2); diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html index bd7e382..a1e2e67 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html @@ -100,7 +100,7 @@

{{ document()!.effective_filename }}

} @else if (imageUrlA()) { @if (isPageAIgnored()) { -
{{ t('reader.ignoredOverlay') }}
+
{{ t('reader.ignoredOverlay') }}
} } @else {
{{ t('reader.imageUnavailable') }}
@@ -194,7 +194,7 @@

{{ document()!.effective_filename }}

} @else if (imageUrlB()) { @if (isPageBIgnored()) { -
{{ t('reader.ignoredOverlay') }}
+
{{ t('reader.ignoredOverlay') }}
} } @else {
{{ t('reader.imageUnavailable') }}
diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss index 157b816..450902d 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss @@ -229,13 +229,12 @@ align-items: center; justify-content: center; pointer-events: none; - font-size: 0; - &::after { - content: 'IGNORED'; + &__badge { font-size: 1rem; font-weight: 700; letter-spacing: 0.1em; + text-transform: uppercase; padding: 0.25rem 0.75rem; background: var(--color-fg-muted); color: var(--color-surface-2); From 3e2f5723d4b513034d742c111be799a6ea67982e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 14:37:35 +0300 Subject: [PATCH 11/17] [WKP-36] Localize timestamps to active locale Card formatDate + profile expiry + compendium header date hardcoded en-US (AM/PM, month-first). Add formatLocaleDate/formatDateTime helpers (en->en-US, ro->ro-RO) driven by transloco.getActiveLang(). RO now 24h + RO month/order; EN unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../layout-compendium/layout-compendium.ts | 6 ++- .../src/app/pages/book-view/book-view.ts | 6 +-- .../pages/document-detail/document-detail.ts | 9 +---- .../src/app/pages/documents/documents.ts | 9 +---- .../src/app/pages/extraction/extraction.ts | 9 +---- .../app/pages/page-linking/page-linking.ts | 9 +---- .../app/pages/profile/info/profile-info.ts | 3 +- .../src/app/pages/translation/translation.ts | 6 +-- .../src/app/utils/date-format.spec.ts | 37 ++++++++++++++++++- wordkeep-client/src/app/utils/date-format.ts | 29 +++++++++++++++ 10 files changed, 83 insertions(+), 40 deletions(-) diff --git a/wordkeep-client/src/app/components/layout/variants/layout-compendium/layout-compendium.ts b/wordkeep-client/src/app/components/layout/variants/layout-compendium/layout-compendium.ts index c0c02b8..095e222 100644 --- a/wordkeep-client/src/app/components/layout/variants/layout-compendium/layout-compendium.ts +++ b/wordkeep-client/src/app/components/layout/variants/layout-compendium/layout-compendium.ts @@ -1,7 +1,8 @@ import { Component, HostListener, Input, TemplateRef, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink, RouterLinkActive } from '@angular/router'; -import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; +import { formatLocaleDate } from '../../../../utils/date-format'; import { AuthService } from '../../../../services/auth.service'; import { ThemeToggleComponent } from '../../../theme-toggle/theme-toggle'; import { UserMenuDropdownComponent } from '../../../user-menu-dropdown/user-menu-dropdown'; @@ -16,12 +17,13 @@ import { UserMenuDropdownComponent } from '../../../user-menu-dropdown/user-menu }) export class LayoutCompendiumComponent { authService = inject(AuthService); + private transloco = inject(TranslocoService); menuOpen = signal(false); @Input() contentTpl: TemplateRef | null = null; readonly today = computed(() => - new Date().toLocaleDateString(undefined, { + formatLocaleDate(new Date(), this.transloco.getActiveLang(), { weekday: 'long', year: 'numeric', month: 'long', diff --git a/wordkeep-client/src/app/pages/book-view/book-view.ts b/wordkeep-client/src/app/pages/book-view/book-view.ts index 8fa8ac1..df2aa60 100644 --- a/wordkeep-client/src/app/pages/book-view/book-view.ts +++ b/wordkeep-client/src/app/pages/book-view/book-view.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { forkJoin, interval, Subscription } from 'rxjs'; +import { formatDateTime } from '../../utils/date-format'; import { DocumentService } from '../../services/document.service'; import { BookViewService } from '../../services/book-view.service'; import { ConfirmationService } from '../../services/confirmation.service'; @@ -237,9 +238,6 @@ export class BookViewComponent implements OnInit, OnDestroy { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang()); } } diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.ts b/wordkeep-client/src/app/pages/document-detail/document-detail.ts index 563e8aa..6e57ebd 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.ts +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { interval, Subscription } from 'rxjs'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; +import { formatDateTime } from '../../utils/date-format'; import { DocumentService } from '../../services/document.service'; import { BookViewService } from '../../services/book-view.service'; import { ConfirmationService } from '../../services/confirmation.service'; @@ -376,13 +377,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang(), 'long'); } getMimeTypeLabel(mimeType: string): string { diff --git a/wordkeep-client/src/app/pages/documents/documents.ts b/wordkeep-client/src/app/pages/documents/documents.ts index 0a45584..556f598 100644 --- a/wordkeep-client/src/app/pages/documents/documents.ts +++ b/wordkeep-client/src/app/pages/documents/documents.ts @@ -1,6 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { formatDateTime } from '../../utils/date-format'; import { FormsModule } from '@angular/forms'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService } from '../../services/document.service'; @@ -194,13 +195,7 @@ export class DocumentsComponent implements OnInit { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang()); } getStatusClass(status: string): string { diff --git a/wordkeep-client/src/app/pages/extraction/extraction.ts b/wordkeep-client/src/app/pages/extraction/extraction.ts index 7732406..34f3424 100644 --- a/wordkeep-client/src/app/pages/extraction/extraction.ts +++ b/wordkeep-client/src/app/pages/extraction/extraction.ts @@ -4,6 +4,7 @@ import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { interval, Subscription } from 'rxjs'; +import { formatDateTime } from '../../utils/date-format'; import { DocumentService } from '../../services/document.service'; import { BulkOcrService } from '../../services/bulk-ocr.service'; import { ConfirmationService } from '../../services/confirmation.service'; @@ -107,13 +108,7 @@ export class ExtractionComponent implements OnInit, OnDestroy { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang()); } goToPage(page: number) { diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.ts b/wordkeep-client/src/app/pages/page-linking/page-linking.ts index 12d0bec..bc90cf8 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.ts +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.ts @@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; +import { formatDateTime } from '../../utils/date-format'; import { DocumentService } from '../../services/document.service'; import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; @@ -73,13 +74,7 @@ export class PageLinkingComponent implements OnInit { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang()); } getResumePage(doc: Document): number { diff --git a/wordkeep-client/src/app/pages/profile/info/profile-info.ts b/wordkeep-client/src/app/pages/profile/info/profile-info.ts index 9ffacaa..a113a23 100644 --- a/wordkeep-client/src/app/pages/profile/info/profile-info.ts +++ b/wordkeep-client/src/app/pages/profile/info/profile-info.ts @@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; +import { formatLocaleDate } from '../../../utils/date-format'; import { AuthService } from '../../../services/auth.service'; import { ProfileService } from '../../../services/profile.service'; import { AvatarCropDialogComponent } from '../../../components/avatar-crop-dialog/avatar-crop-dialog'; @@ -197,6 +198,6 @@ export class ProfileInfoComponent implements OnInit { formatExpiry(iso: string | null | undefined): string { if (!iso) return ''; - return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + return formatLocaleDate(iso, this.transloco.getActiveLang(), { month: 'short', day: 'numeric', year: 'numeric' }); } } diff --git a/wordkeep-client/src/app/pages/translation/translation.ts b/wordkeep-client/src/app/pages/translation/translation.ts index 273d10f..e36752b 100644 --- a/wordkeep-client/src/app/pages/translation/translation.ts +++ b/wordkeep-client/src/app/pages/translation/translation.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { interval, Subscription } from 'rxjs'; +import { formatDateTime } from '../../utils/date-format'; import { TranslationService } from '../../services/translation.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { NewTranslationDialogService } from '../../services/new-translation-dialog.service'; @@ -252,10 +253,7 @@ export class TranslationComponent implements OnInit, OnDestroy { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', month: 'short', day: 'numeric', - hour: '2-digit', minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang()); } goToPage(page: number) { diff --git a/wordkeep-client/src/app/utils/date-format.spec.ts b/wordkeep-client/src/app/utils/date-format.spec.ts index 7126c4e..7994985 100644 --- a/wordkeep-client/src/app/utils/date-format.spec.ts +++ b/wordkeep-client/src/app/utils/date-format.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatNumericDate } from './date-format'; +import { formatNumericDate, formatDateTime, formatLocaleDate } from './date-format'; describe('formatNumericDate', () => { it('should_format_as_DD_MM_YYYY', () => { @@ -23,3 +23,38 @@ describe('formatNumericDate', () => { expect(formatNumericDate(new Date(2026, 4, 28), 'ro')).toBe('28.05.2026'); }); }); + +describe('formatDateTime', () => { + // Fixed local time so output is timezone-stable across CI. + const dt = new Date(2024, 0, 15, 14, 30); + + it('should_render_english_with_am_pm', () => { + const result = formatDateTime(dt, 'en'); + expect(result).toContain('Jan'); + expect(result).toContain('2024'); + expect(result).toMatch(/[AP]M/); + }); + + it('should_render_romanian_24h_without_am_pm', () => { + const result = formatDateTime(dt, 'ro'); + expect(result).toContain('ian.'); + expect(result).toContain('14:30'); + expect(result).not.toMatch(/[AP]M/); + }); + + it('should_support_long_month', () => { + expect(formatDateTime(dt, 'en', 'long')).toContain('January'); + }); + + it('should_default_unknown_locale_to_english', () => { + expect(formatDateTime(dt, 'fr')).toMatch(/[AP]M/); + }); +}); + +describe('formatLocaleDate', () => { + it('should_format_date_only_per_locale', () => { + const d = new Date(2024, 0, 15); + expect(formatLocaleDate(d, 'en')).toContain('Jan'); + expect(formatLocaleDate(d, 'ro')).toContain('ian.'); + }); +}); diff --git a/wordkeep-client/src/app/utils/date-format.ts b/wordkeep-client/src/app/utils/date-format.ts index d1a205b..8103905 100644 --- a/wordkeep-client/src/app/utils/date-format.ts +++ b/wordkeep-client/src/app/utils/date-format.ts @@ -13,3 +13,32 @@ export function formatNumericDate(input: string | Date, locale: NumericDateLocal const sep = locale === 'ro' ? '.' : '/'; return `${day}${sep}${month}${sep}${year}`; } + +// Locale tags for Intl date/time: en keeps US (AM/PM, month-first) per spec; +// ro uses 24h + RO month/ordering. +const DATETIME_TAGS: Record = { en: 'en-US', ro: 'ro-RO' }; + +/** Locale-aware date/time format. Pass Intl options to control what's shown. */ +export function formatLocaleDate( + input: string | Date, + locale: string = 'en', + options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' } +): string { + const tag = DATETIME_TAGS[(locale as NumericDateLocale)] ?? 'en-US'; + return new Date(input).toLocaleString(tag, options); +} + +/** + * Document-card timestamp (date + time). Thin wrapper so the card sites stay + * one-liners and don't duplicate the options object. + * en → "Jan 15, 2024, 10:30 AM"; ro → "15 ian. 2024, 12:30" (24h). + */ +export function formatDateTime( + input: string | Date, + locale: string = 'en', + month: 'short' | 'long' = 'short' +): string { + return formatLocaleDate(input, locale, { + year: 'numeric', month, day: 'numeric', hour: '2-digit', minute: '2-digit' + }); +} From 4e1b1696d0b43ed538482956bdff0c1b08b25ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 17:35:04 +0300 Subject: [PATCH 12/17] [WKP-36] Console layout: ANSI-dot badges + localize status bar/nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badges (status/renamed/type/lang/wrong-lang) become borderless lowercase mono tokens with a currentColor dot, distinct from scriptorium cartouches. Status bar shows localized palette + theme labels; RO nav tokens (doc/extr/leg/viz/trad/dicț). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/assets/i18n/shell/en.json | 14 ++++++- .../public/assets/i18n/shell/ro.json | 24 ++++++++--- .../layout-console/layout-console.html | 4 +- wordkeep-client/src/styles.scss | 40 ++++++++++++++----- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/wordkeep-client/public/assets/i18n/shell/en.json b/wordkeep-client/public/assets/i18n/shell/en.json index a8b5a84..d301eb1 100644 --- a/wordkeep-client/public/assets/i18n/shell/en.json +++ b/wordkeep-client/public/assets/i18n/shell/en.json @@ -33,6 +33,18 @@ "dict": "dict", "openPalette": "Open command palette", "statusBrand": "[wordkeep]", - "navHint": "press / to navigate" + "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 index ef5c23b..976a0e2 100644 --- a/wordkeep-client/public/assets/i18n/shell/ro.json +++ b/wordkeep-client/public/assets/i18n/shell/ro.json @@ -25,14 +25,26 @@ }, "console": { "promptUser": "wordkeep", - "docs": "docs", + "docs": "doc", "extr": "extr", - "link": "link", - "viewNav": "view", - "trsl": "trsl", - "dict": "dict", + "link": "leg", + "viewNav": "viz", + "trsl": "trad", + "dict": "dicț", "openPalette": "Deschide paleta de comenzi", "statusBrand": "[wordkeep]", - "navHint": "apăsați / pentru a naviga" + "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/src/app/components/layout/variants/layout-console/layout-console.html b/wordkeep-client/src/app/components/layout/variants/layout-console/layout-console.html index 4c04ee0..0adc0ee 100644 --- a/wordkeep-client/src/app/components/layout/variants/layout-console/layout-console.html +++ b/wordkeep-client/src/app/components/layout/variants/layout-console/layout-console.html @@ -57,9 +57,9 @@ {{ t('console.statusBrand') }} {{ currentPath() }} - {{ themeService.palette() }} + {{ t('console.palette.' + themeService.palette()) }} · - {{ themeService.resolved() }} + {{ t('console.theme.' + themeService.resolved()) }} · {{ authService.userName() }} · diff --git a/wordkeep-client/src/styles.scss b/wordkeep-client/src/styles.scss index 3744534..1fcb390 100644 --- a/wordkeep-client/src/styles.scss +++ b/wordkeep-client/src/styles.scss @@ -183,21 +183,43 @@ select { // titles drop to mono with a leading $/# prompt. [data-layout='console'] { + // Badges become borderless "ANSI" tokens: a leading dot in the badge's own + // colour + a lowercase mono label. The dot inherits `currentColor`, so each + // badge keeps its semantic colour (status, language, type…) with no per-type + // rules. Deliberately unlike scriptorium's uppercase tilted double-border + // cartouche. .doc-status, - .renamed-badge { + .renamed-badge, + .type-badge, + .lang-badge, + .wrong-lang-badge { + display: inline-flex !important; + align-items: center; + gap: 0.4em; background: transparent !important; - border: 1px solid currentColor !important; + border: none !important; + box-shadow: none !important; + transform: none !important; border-radius: 0 !important; - padding: 0.05rem 0.35rem !important; + padding: 0 !important; font-family: var(--font-mono) !important; - font-size: 0.62rem !important; - font-weight: 600 !important; - letter-spacing: 0.08em !important; - text-transform: uppercase; + font-size: 0.8rem !important; + font-weight: 500 !important; + letter-spacing: 0.01em !important; + text-transform: lowercase !important; position: relative; - &::before { content: '['; margin-right: 0.1rem; opacity: 0.5; } - &::after { content: ']'; margin-left: 0.1rem; opacity: 0.5; } + &::before { + content: '' !important; + flex-shrink: 0; + width: 0.5em; + height: 0.5em; + margin: 0 !important; + border-radius: 50%; + background: currentColor; + opacity: 1; + } + &::after { content: none !important; } } // Section H1/H2: command-style header, mono, with leading prompt. From b3af6ea72bddafcd37a0ea5a1d9f9e748d6d577f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 17:35:51 +0300 Subject: [PATCH 13/17] [WKP-36] Localize doc status + personalisation subtitle; fix dictionary alignment Document status badge now localized (uploaded/processing/ready/failed). Personalisation subtitle mentions language. Dictionary word/badge vertically aligned (align-items: center). Co-Authored-By: Claude Opus 4.8 (1M context) --- wordkeep-client/public/assets/i18n/documents/en.json | 6 ++++++ wordkeep-client/public/assets/i18n/documents/ro.json | 6 ++++++ wordkeep-client/public/assets/i18n/profile/en.json | 2 +- wordkeep-client/public/assets/i18n/profile/ro.json | 2 +- wordkeep-client/src/app/pages/dictionary/dictionary.scss | 2 +- wordkeep-client/src/app/pages/documents/documents.html | 4 ++-- 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/wordkeep-client/public/assets/i18n/documents/en.json b/wordkeep-client/public/assets/i18n/documents/en.json index e3d1122..4f348c3 100644 --- a/wordkeep-client/public/assets/i18n/documents/en.json +++ b/wordkeep-client/public/assets/i18n/documents/en.json @@ -18,6 +18,12 @@ "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}}", diff --git a/wordkeep-client/public/assets/i18n/documents/ro.json b/wordkeep-client/public/assets/i18n/documents/ro.json index 8d5275e..32f6c6f 100644 --- a/wordkeep-client/public/assets/i18n/documents/ro.json +++ b/wordkeep-client/public/assets/i18n/documents/ro.json @@ -18,6 +18,12 @@ "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}}", diff --git a/wordkeep-client/public/assets/i18n/profile/en.json b/wordkeep-client/public/assets/i18n/profile/en.json index a472418..28bba79 100644 --- a/wordkeep-client/public/assets/i18n/profile/en.json +++ b/wordkeep-client/public/assets/i18n/profile/en.json @@ -7,7 +7,7 @@ }, "personalisation": { "title": "Personalisation", - "subtitle": "Pick a layout, a palette, and a colour mode.", + "subtitle": "Pick a layout, a palette, a colour mode, and a language.", "layoutHeading": "Layout", "paletteHeading": "Palette", "modeHeading": "Colour mode", diff --git a/wordkeep-client/public/assets/i18n/profile/ro.json b/wordkeep-client/public/assets/i18n/profile/ro.json index 3c4510d..cd186fd 100644 --- a/wordkeep-client/public/assets/i18n/profile/ro.json +++ b/wordkeep-client/public/assets/i18n/profile/ro.json @@ -7,7 +7,7 @@ }, "personalisation": { "title": "Personalizare", - "subtitle": "Alege un aspect, o paletă și un mod de culoare.", + "subtitle": "Alege un aspect, o paletă, un mod de culoare și o limbă.", "layoutHeading": "Aspect", "paletteHeading": "Paletă", "modeHeading": "Mod de culoare", diff --git a/wordkeep-client/src/app/pages/dictionary/dictionary.scss b/wordkeep-client/src/app/pages/dictionary/dictionary.scss index da649b0..e4c1ff8 100644 --- a/wordkeep-client/src/app/pages/dictionary/dictionary.scss +++ b/wordkeep-client/src/app/pages/dictionary/dictionary.scss @@ -147,7 +147,7 @@ .word-item { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; diff --git a/wordkeep-client/src/app/pages/documents/documents.html b/wordkeep-client/src/app/pages/documents/documents.html index 79021d2..ac85aa4 100644 --- a/wordkeep-client/src/app/pages/documents/documents.html +++ b/wordkeep-client/src/app/pages/documents/documents.html @@ -100,7 +100,7 @@

{{ doc.effective_filename }}

@if (doc.error_message) { @@ -132,7 +132,7 @@

{{ doc.effective_filename }}

From eb16ad43e4a0f358fb21f17ef890332bbdaa3c34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 17:36:03 +0300 Subject: [PATCH 14/17] [WKP-36] Add i18n:translate npm script Wire scripts/translate-i18n.mjs (Gemini en->ro seeding) as npm run i18n:translate. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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", From a4d6ce95f17998cb7c4f0b1f9667a4554e65de36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 17:45:29 +0300 Subject: [PATCH 15/17] [WKP-36] Bump Playwright to 1.60.0 to fix install hang playwright install hung at 100% (download done, extraction stalled) due to a yauzl regression on Node 24.16+/26.x; fixed in Playwright 1.60.0. Was pinned to 1.58.2. Affected CI (node lts/*) and local (Node 26). Verified chromium install now completes. Co-Authored-By: Claude Opus 4.8 (1M context) --- wordkeep-client/package-lock.json | 24 ++++++++++++------------ wordkeep-client/package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/wordkeep-client/package-lock.json b/wordkeep-client/package-lock.json index 43c1420..7051a96 100644 --- a/wordkeep-client/package-lock.json +++ b/wordkeep-client/package-lock.json @@ -36,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" @@ -3321,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" @@ -7512,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" @@ -7531,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": { diff --git a/wordkeep-client/package.json b/wordkeep-client/package.json index a5d5bfb..ef056fa 100644 --- a/wordkeep-client/package.json +++ b/wordkeep-client/package.json @@ -53,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" From e76138531012c40bd67adbf32a33133d40f72876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 9 Jun 2026 17:55:38 +0300 Subject: [PATCH 16/17] [WKP-36] Fix flaky BulkTranslate page-linking test The test created a lone page (page_number=1) expecting link_to_next_page to stay null, but DocumentFactory's page_count is random (1-50). When page_count==1, DocumentPage's creating hook stamps the last page with DOCUMENT_END, making the page linked and flaking the assertion (~2% of runs). Pin page_count=2 so page 1 is never the last page. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Feature/BulkTranslateDocumentJobTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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, From ea3c973a2536407f4276fa7543e2b77583dca83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gu=C8=99=C4=83?= Date: Tue, 16 Jun 2026 17:01:34 +0300 Subject: [PATCH 17/17] [WKP-36] Make translate-i18n.mjs target any language via CLI arg Required arg + self-contained LANG_NAMES map; parameterize prompt, output paths and logs. Adapt ICU plural categories to target CLDR; make placeholder parity ICU-aware. Retry transient Gemini failures (429/5xx, network, bad body) with backoff. Update README + memory mirror. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 7 +- docs/claude-code-memories/MEMORY.md | 1 + .../standalone-scripts-self-sufficient.md | 16 ++ scripts/translate-i18n.mjs | 172 +++++++++++++----- 4 files changed, 152 insertions(+), 44 deletions(-) create mode 100644 docs/claude-code-memories/standalone-scripts-self-sufficient.md diff --git a/README.md b/README.md index 99a32e5..3aeda4a 100644 --- a/README.md +++ b/README.md @@ -476,11 +476,12 @@ The UI and backend messages ship in English (`en`, default) and Romanian (`ro`). ### 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: +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 # creates .json siblings + GEMINI_API_KEY=… node scripts/translate-i18n.mjs # creates .json siblings + # or: npm run i18n:translate -- ``` - The script preserves placeholders and ICU plurals and merges on re-run (reviewed strings are never overwritten). Then mirror `lang/en/` to `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//`. 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 diff --git a/docs/claude-code-memories/MEMORY.md b/docs/claude-code-memories/MEMORY.md index 1260c12..aebd130 100644 --- a/docs/claude-code-memories/MEMORY.md +++ b/docs/claude-code-memories/MEMORY.md @@ -21,3 +21,4 @@ - [Never migrate:fresh without approval](never-migrate-fresh-without-approval.md) — destructive DB commands require explicit approval - [Frontend tests use npm test](frontend-tests-use-npm-test.md) — run via `npm test`, not `npx vitest` directly - [i18n Transloco architecture](i18n-transloco-architecture.md) — asset path, no TranslocoService in shared services, scope resolution, test setup +- [Standalone scripts self-sufficient](standalone-scripts-self-sufficient.md) — embed reference data in tooling scripts, don't read app/client assets 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/scripts/translate-i18n.mjs b/scripts/translate-i18n.mjs index 02a6bd8..ab6db34 100644 --- a/scripts/translate-i18n.mjs +++ b/scripts/translate-i18n.mjs @@ -1,24 +1,26 @@ /** - * Seeds / updates Romanian (ro.json) translation files from their English - * (en.json) siblings using Google Gemini 2.5 Flash. + * 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 ro.json (if any). - * 2. Finds keys present in en but MISSING from ro (merge mode — reviewed - * Romanian strings are never overwritten). + * 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 ICU plural syntax. - * 4. Validates placeholder parity, then writes a merged ro.json whose key + * {{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 + * 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 ro.json diff and fix terminology before committing. + * Review the generated .json diff and fix terminology before committing. */ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; @@ -29,9 +31,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, '..'); const i18nDir = join(repoRoot, 'wordkeep-client/public/assets/i18n'); -const args = new Set(process.argv.slice(2)); -const RETRANSLATE_ALL = args.has('--all'); -const DRY_RUN = args.has('--dry-run'); +// ── 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() { @@ -53,6 +80,13 @@ 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); @@ -93,8 +127,16 @@ function rebuildLike(enObj, flatValues, prefix = '') { } // ── 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) { - return (String(s).match(/\{\{?\s*[^}]+?\s*\}?\}/g) || []).map(t => t.replace(/\s+/g, '')).sort(); + 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); @@ -105,35 +147,82 @@ function samePlaceholders(a, b) { async function translateBatch(pairs) { // pairs: { dotKey: englishValue } const prompt = [ - 'You are translating UI strings for the WordKeep app from English to Romanian.', + `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.', - '- Preserve ICU plural/select syntax exactly (e.g. {count, plural, one {...} other {...}}).', + `- 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 Romanian appropriate for a literary document-keeping app.', + `- 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 Romanian strings.', + `Return ONLY a JSON object mapping the same keys to the translated ${TARGET_NAME} strings.`, '', JSON.stringify(pairs, null, 2), ].join('\n'); - const res = await fetch(`${BASE_URL}/models/${MODEL}:generateContent?key=${API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contents: [{ parts: [{ text: prompt }] }], - generationConfig: { temperature: 0.2, responseMimeType: 'application/json' }, - }), + return requestWithRetry({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.2, responseMimeType: 'application/json' }, }); +} - if (!res.ok) { - throw new Error(`Gemini HTTP ${res.status}: ${await res.text()}`); +/** + * 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})`); + } } - const data = await res.json(); - const text = data?.candidates?.[0]?.content?.parts?.[0]?.text; - if (!text) throw new Error('Gemini returned no text'); - return JSON.parse(text); +} + +/** 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 ────────────────────────────────────────────── @@ -151,18 +240,18 @@ function findEnFiles() { } async function processFile(enPath) { - const roPath = enPath.replace(/en\.json$/, 'ro.json'); + const targetPath = enPath.replace(/en\.json$/, `${TARGET}.json`); const rel = enPath.slice(i18nDir.length + 1); const en = JSON.parse(readFileSync(enPath, 'utf8')); - const ro = existsSync(roPath) ? JSON.parse(readFileSync(roPath, 'utf8')) : {}; + const existing = existsSync(targetPath) ? JSON.parse(readFileSync(targetPath, 'utf8')) : {}; const enFlat = flatten(en); - const roFlat = flatten(ro); + const targetFlat = flatten(existing); const todo = {}; for (const [k, v] of Object.entries(enFlat)) { if (typeof v !== 'string') continue; - if (RETRANSLATE_ALL || !(k in roFlat) || roFlat[k] === undefined || roFlat[k] === '') { + if (RETRANSLATE_ALL || !(k in targetFlat) || targetFlat[k] === undefined || targetFlat[k] === '') { todo[k] = v; } } @@ -180,8 +269,8 @@ async function processFile(enPath) { const translated = await translateBatch(todo); - // Start from existing ro (reviewed values kept), apply translations. - const mergedFlat = { ...roFlat }; + // 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') { @@ -198,12 +287,13 @@ async function processFile(enPath) { } const merged = rebuildLike(en, mergedFlat); - writeFileSync(roPath, JSON.stringify(merged, null, 2) + '\n', 'utf8'); - console.log(` → wrote ${rel.replace(/en\.json$/, 'ro.json')}`); + 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 { @@ -213,4 +303,4 @@ for (const f of enFiles) { process.exitCode = 1; } } -console.log('\nDone. Review the ro.json diffs and fix terminology before committing.'); +console.log(`\nDone. Review the ${TARGET}.json diffs and fix terminology before committing.`);