diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index 48ef45f9f..cc9764fbc 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -4,3 +4,4 @@ playwright-report/ i18n-cache-busting.json e2e-tests/environments/environment.local.ts dead-i18n-keys.json +i18n-audit-report.json diff --git a/UI/Web/README.md b/UI/Web/README.md index a5a21a4b5..4c823fbe3 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -15,15 +15,16 @@ Run `ng generate component component-name` to generate a new component. You can Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. -## Running unit tests -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). - -## Running end-to-end tests - -~~Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).~~ - -Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests. +## Localization Scripts +- audit-i18n.js (ran via `npm run audit-i18n`) + - Performs Duplicate key detection + - Cross-Reference Validation ({common.roles}}, {{common.copy}}, {{common.required-field}} don't exist in en.json) + - Dead Keys - Potentially finds dead keys. Some dynamically created keys may be false-positive, use `i18n-dynamic-keys.json` to whitelist prefixes + - Locale Sync - Shows missing/empty/extra key counts per locale. + - Outputs to i18n-audit-report.json +- /locale-preview + - An authenticated page to let any authenticated user see locales and ensure their target language has all necessary keys ## Connecting to your dev server via your phone or any other compatible client on local network diff --git a/UI/Web/audit-i18n.js b/UI/Web/audit-i18n.js new file mode 100644 index 000000000..865669647 --- /dev/null +++ b/UI/Web/audit-i18n.js @@ -0,0 +1,359 @@ +const fs = require('fs'); +const path = require('path'); +const glob = require('glob'); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function flattenKeys(obj, prefix = '') { + const result = new Map(); + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null) { + for (const [k, v] of flattenKeys(value, fullKey)) { + result.set(k, v); + } + } else if (typeof value === 'string') { + result.set(fullKey, value); + } + } + return result; +} + +// Matches cross-references like {{common.save}} but NOT runtime params like {{num}} +// Cross-refs have a dot in the identifier; params do not +const CROSS_REF_RE = /\{\{([\w-]+\.[\w-]+(?:\.[\w-]+)*)\}\}/g; + +function isCrossRef(value) { + return /\{\{[\w-]+\.[\w-]+(?:\.[\w-]+)*\}\}/.test(value); +} + +function extractCrossRefs(value) { + const refs = []; + let match; + const re = new RegExp(CROSS_REF_RE.source, 'g'); + while ((match = re.exec(value)) !== null) { + refs.push(match[1]); + } + return refs; +} + +// ─── 1. Duplicate Value Finder ────────────────────────────────────────────── + +function findDuplicateValues(flatMap) { + const valueToKeys = new Map(); + for (const [key, value] of flatMap) { + // Skip cross-references and short strings + if (isCrossRef(value) || value.length <= 3) continue; + // Skip values that are pure parameter interpolation + if (/^\{\{[^.]+\}\}$/.test(value)) continue; + + if (!valueToKeys.has(value)) { + valueToKeys.set(value, []); + } + valueToKeys.get(value).push(key); + } + + const duplicates = []; + for (const [value, keys] of valueToKeys) { + if (keys.length > 1) { + // Check if any key is already in common.* + const commonKey = keys.find(k => k.startsWith('common.')); + duplicates.push({ + value, + count: keys.length, + keys, + suggestion: commonKey + ? `Already in common: ${commonKey} — other keys can use {{${commonKey}}}` + : `Consider adding to common.* and referencing via {{common.xxx}}` + }); + } + } + return duplicates.sort((a, b) => b.count - a.count); +} + +// ─── 2. Cross-Reference Validator ─────────────────────────────────────────── + +function validateCrossRefs(flatMap) { + const broken = []; + const circular = []; + + // Build ref graph + const graph = new Map(); + for (const [key, value] of flatMap) { + const refs = extractCrossRefs(value); + if (refs.length > 0) { + graph.set(key, refs); + } + } + + // Check broken refs + for (const [key, refs] of graph) { + for (const ref of refs) { + if (!flatMap.has(ref)) { + broken.push({ key, ref, value: flatMap.get(key) }); + } + } + } + + // Check circular refs via DFS + function hasCycle(startKey, visited = new Set(), path = new Set()) { + if (path.has(startKey)) return true; + if (visited.has(startKey)) return false; + + visited.add(startKey); + path.add(startKey); + + const refs = graph.get(startKey) || []; + for (const ref of refs) { + if (hasCycle(ref, visited, path)) { + circular.push({ key: startKey, cycle: [...path, ref].join(' → ') }); + return true; + } + } + path.delete(startKey); + return false; + } + + const visited = new Set(); + for (const key of graph.keys()) { + if (!visited.has(key)) { + hasCycle(key, visited, new Set()); + } + } + + return { broken, circular }; +} + +// ─── 3. Dead Key Finder ───────────────────────────────────────────────────── + +function findUsedKeys(srcDir) { + const usedKeys = new Set(); + + // Scan HTML files + const htmlFiles = glob.sync('**/*.html', { cwd: srcDir, absolute: true }); + for (const file of htmlFiles) { + const content = fs.readFileSync(file, 'utf8'); + + // Find transloco prefix directives + const prefixRe = /\*transloco\s*=\s*"[^"]*prefix\s*:\s*'([^']+)'/g; + let prefixMatch; + const prefixes = []; + while ((prefixMatch = prefixRe.exec(content)) !== null) { + prefixes.push(prefixMatch[1]); + } + + // Find t('key') calls in template + const tCallRe = /t\(\s*'([^']+)'\s*\)/g; + let tMatch; + while ((tMatch = tCallRe.exec(content)) !== null) { + const key = tMatch[1]; + if (key.includes('.')) { + // Fully qualified key + usedKeys.add(key); + } else { + // Scoped key — combine with each prefix found in this file + for (const prefix of prefixes) { + usedKeys.add(`${prefix}.${key}`); + } + } + } + + // Also find transloco pipe usage: 'key' | transloco + const pipeRe = /'([^']+)'\s*\|\s*transloco/g; + let pipeMatch; + while ((pipeMatch = pipeRe.exec(content)) !== null) { + usedKeys.add(pipeMatch[1]); + } + } + + // Scan TS files + const tsFiles = glob.sync('**/*.ts', { cwd: srcDir, absolute: true, ignore: ['**/*.spec.ts', '**/node_modules/**'] }); + for (const file of tsFiles) { + const content = fs.readFileSync(file, 'utf8'); + + // translate('full.key') or translocoService.translate('full.key') + const translateRe = /translate\(\s*'([^']+)'/g; + let trMatch; + while ((trMatch = translateRe.exec(content)) !== null) { + usedKeys.add(trMatch[1]); + } + + // Also: translate("full.key") + const translateDqRe = /translate\(\s*"([^"]+)"/g; + while ((trMatch = translateDqRe.exec(content)) !== null) { + usedKeys.add(trMatch[1]); + } + + // selectTranslate('full.key') + const selectRe = /selectTranslate\(\s*'([^']+)'/g; + while ((trMatch = selectRe.exec(content)) !== null) { + usedKeys.add(trMatch[1]); + } + } + + return usedKeys; +} + +function findDeadKeys(flatMap, srcDir, allowlistPath) { + const usedKeys = findUsedKeys(srcDir); + + // Load dynamic key allowlist + let allowlist = []; + if (fs.existsSync(allowlistPath)) { + allowlist = JSON.parse(fs.readFileSync(allowlistPath, 'utf8')); + } + + // Build set of keys that are referenced via cross-refs (transitively used) + const referencedKeys = new Set(); + for (const [, value] of flatMap) { + for (const ref of extractCrossRefs(value)) { + referencedKeys.add(ref); + } + } + + const deadKeys = []; + for (const [key] of flatMap) { + // Skip if directly used in code + if (usedKeys.has(key)) continue; + // Skip if referenced by another key via cross-ref + if (referencedKeys.has(key)) continue; + // Skip if covered by dynamic allowlist prefix + if (allowlist.some(prefix => key.startsWith(prefix))) continue; + + deadKeys.push(key); + } + + return deadKeys.sort(); +} + +// ─── 4. Key Sync Report ───────────────────────────────────────────────────── + +function syncReport(enFlat, langDir) { + const localeFiles = fs.readdirSync(langDir) + .filter(f => f.endsWith('.json') && f !== 'en.json'); + + const report = {}; + for (const file of localeFiles) { + const locale = file.replace('.json', ''); + const data = JSON.parse(fs.readFileSync(path.join(langDir, file), 'utf8')); + const localeFlat = flattenKeys(data); + + const missing = []; + const empty = []; + for (const [key, value] of enFlat) { + if (!localeFlat.has(key)) { + // Skip keys that are cross-refs (they should be identical across locales) + if (!isCrossRef(value)) { + missing.push(key); + } + } else if (localeFlat.get(key) === '' && !isCrossRef(value)) { + empty.push(key); + } + } + + const extra = []; + for (const [key] of localeFlat) { + if (!enFlat.has(key)) { + extra.push(key); + } + } + + report[locale] = { + totalKeys: localeFlat.size, + missing: missing.length, + empty: empty.length, + extra: extra.length, + missingKeys: missing, + emptyKeys: empty, + extraKeys: extra + }; + } + return report; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +function main() { + const webDir = path.resolve(__dirname); + const langDir = path.join(webDir, 'src', 'assets', 'langs'); + const srcDir = path.join(webDir, 'src', 'app'); + const enPath = path.join(langDir, 'en.json'); + const allowlistPath = path.join(webDir, 'i18n-dynamic-keys.json'); + const reportPath = path.join(webDir, 'i18n-audit-report.json'); + + if (!fs.existsSync(enPath)) { + console.error('en.json not found at', enPath); + process.exit(1); + } + + const enData = JSON.parse(fs.readFileSync(enPath, 'utf8')); + const enFlat = flattenKeys(enData); + + console.log(`\n📊 i18n Audit Report`); + console.log(`${'─'.repeat(60)}`); + console.log(`Total keys in en.json: ${enFlat.size}\n`); + + // 1. Duplicates + console.log('1️⃣ Duplicate Values'); + const duplicates = findDuplicateValues(enFlat); + console.log(` Found ${duplicates.length} duplicate string values`); + const topDupes = duplicates.slice(0, 10); + for (const d of topDupes) { + console.log(` "${d.value}" appears ${d.count}x — ${d.suggestion}`); + } + if (duplicates.length > 10) { + console.log(` ... and ${duplicates.length - 10} more (see report)`); + } + + // 2. Cross-reference validation + console.log('\n2️⃣ Cross-Reference Validation'); + const { broken, circular } = validateCrossRefs(enFlat); + console.log(` Broken refs: ${broken.length}`); + for (const b of broken.slice(0, 10)) { + console.log(` ❌ ${b.key} references {{${b.ref}}} — target does not exist`); + } + if (broken.length > 10) { + console.log(` ... and ${broken.length - 10} more (see report)`); + } + console.log(` Circular refs: ${circular.length}`); + for (const c of circular) { + console.log(` 🔄 ${c.cycle}`); + } + + // 3. Dead keys + console.log('\n3️⃣ Dead Keys (not found in source code)'); + const deadKeys = findDeadKeys(enFlat, srcDir, allowlistPath); + console.log(` Found ${deadKeys.length} potentially dead keys`); + for (const k of deadKeys.slice(0, 15)) { + console.log(` 🪦 ${k}`); + } + if (deadKeys.length > 15) { + console.log(` ... and ${deadKeys.length - 15} more (see report)`); + } + + // 4. Sync report + console.log('\n4️⃣ Locale Sync Report'); + const sync = syncReport(enFlat, langDir); + const locales = Object.keys(sync).sort((a, b) => sync[b].missing - sync[a].missing); + console.log(` ${'Locale'.padEnd(10)} ${'Total'.padStart(6)} ${'Missing'.padStart(8)} ${'Empty'.padStart(8)} ${'Extra'.padStart(8)}`); + console.log(` ${'─'.repeat(42)}`); + for (const locale of locales) { + const s = sync[locale]; + console.log(` ${locale.padEnd(10)} ${String(s.totalKeys).padStart(6)} ${String(s.missing).padStart(8)} ${String(s.empty).padStart(8)} ${String(s.extra).padStart(8)}`); + } + + // Write full report + const report = { + generatedAt: new Date().toISOString(), + totalKeys: enFlat.size, + duplicates, + crossRefs: { broken, circular }, + deadKeys, + localeSync: sync + }; + + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + console.log(`\n✅ Full report written to ${path.relative(webDir, reportPath)}`); +} + +main(); diff --git a/UI/Web/i18n-dynamic-keys.json b/UI/Web/i18n-dynamic-keys.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/UI/Web/i18n-dynamic-keys.json @@ -0,0 +1 @@ +[] diff --git a/UI/Web/package.json b/UI/Web/package.json index 26c14768a..d14af3968 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -15,7 +15,8 @@ "prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "lint": "ng lint", - "e2e": "ng e2e" + "e2e": "ng e2e", + "audit-i18n": "node audit-i18n.js" }, "private": true, "dependencies": { diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index be41214e9..0e6a56aae 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -61,6 +61,11 @@ export const routes: Routes = [ path: 'profile', loadChildren: () => import('./_routes/profile-routing.module').then(m => m.routes) }, + { + path: 'locale-preview', + title: 'locale-preview.title', + loadComponent: () => import('./locale-preview/locale-preview.component').then(c => c.LocalePreviewComponent) + }, { path: 'lists', pathMatch: 'full', diff --git a/UI/Web/src/app/locale-preview/locale-preview.component.html b/UI/Web/src/app/locale-preview/locale-preview.component.html new file mode 100644 index 000000000..416e3ee2c --- /dev/null +++ b/UI/Web/src/app/locale-preview/locale-preview.component.html @@ -0,0 +1,151 @@ + +
+

{{t('title')}}

+ + +
+
+ + +
+
+ + +
+
+ {{t('stats-total')}}: {{stats().total | number}} +
+
+ {{t('stats-resolved')}}: {{stats().resolved | number}} +
+
+ {{t('stats-missing')}}: {{stats().missing | number}} +
+
+ {{t('stats-empty')}}: {{stats().empty | number}} +
+
+ {{t('stats-broken')}}: {{stats().broken | number}} +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ {{t('showing-count', {count: filteredEntries().length, total: stats().total})}} +
+ + @if (loading()) { +
+
+ {{t('loading')}} +
+
+ } @else { +
+ + + + + + + + + + @for (entry of filteredEntries(); track entry.key) { + + + + + + } + +
{{t('key-column')}}{{t('english-column')}}{{t('current-locale-column')}} ({{activeLang()}})
+ {{entry.key}} + @if (entry.status !== 'resolved') { + + {{t(entry.status + '-label')}} + + } + + + @if (entry.enChain.length > 0) { +
+ @for (step of entry.enChain; track $index) { + @if (!$first) { + + } + {{step}} + } +
+ } @else { + {{entry.enResolved}} + } +
+ @if (entry.localeValue === null) { + {{t('not-present')}} + } @else if (entry.localeValue === '') { + {{t('empty-string')}} + } @else { + {{entry.localeResolved}} + @if (entry.localeChain.length > 0) { +
+ @for (step of entry.localeChain; track $index) { + @if (!$first) { + + } + {{step}} + } +
+ } + } +
+
+ } +
+
diff --git a/UI/Web/src/app/locale-preview/locale-preview.component.scss b/UI/Web/src/app/locale-preview/locale-preview.component.scss new file mode 100644 index 000000000..0f15ffb60 --- /dev/null +++ b/UI/Web/src/app/locale-preview/locale-preview.component.scss @@ -0,0 +1,22 @@ +.col-key { + width: 30%; +} + +.col-value { + width: 35%; +} + +.key-cell code { + font-size: 0.85em; + word-break: break-all; +} + +.ref-chain { + font-size: 0.8em; + line-height: 1.6; + + .ref-step { + font-size: 0.9em; + padding: 1px 4px; + } +} diff --git a/UI/Web/src/app/locale-preview/locale-preview.component.ts b/UI/Web/src/app/locale-preview/locale-preview.component.ts new file mode 100644 index 000000000..a87716d2e --- /dev/null +++ b/UI/Web/src/app/locale-preview/locale-preview.component.ts @@ -0,0 +1,218 @@ +import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {TranslocoDirective, TranslocoService} from '@jsverse/transloco'; +import {FormsModule} from '@angular/forms'; +import {forkJoin} from 'rxjs'; +import {KavitaLocale} from "../_models/metadata/language"; +import {LocalizationService} from "../_services/localization.service"; +import {DecimalPipe} from "@angular/common"; + +interface KeyEntry { + key: string; + enValue: string; + enResolved: string; + enChain: string[]; + localeValue: string | null; + localeResolved: string | null; + localeChain: string[]; + status: 'resolved' | 'missing' | 'empty' | 'broken-ref'; +} + +@Component({ + selector: 'app-locale-preview', + imports: [TranslocoDirective, FormsModule, DecimalPipe], + templateUrl: './locale-preview.component.html', + styleUrl: './locale-preview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class LocalePreviewComponent implements OnInit { + private readonly http = inject(HttpClient); + private readonly translocoService = inject(TranslocoService); + private readonly localizationService = inject(LocalizationService); + + languages = signal([]); + entries = signal([]); + activeLang = signal('en'); + loading = signal(true); + searchQuery = signal(''); + + hideResolved = signal(false); + showOnlyMissing = signal(false); + showOnlyBroken = signal(false); + showOnlyEmpty = signal(false); + + stats = computed(() => { + const all = this.entries(); + return { + total: all.length, + resolved: all.filter(e => e.status === 'resolved').length, + missing: all.filter(e => e.status === 'missing').length, + empty: all.filter(e => e.status === 'empty').length, + broken: all.filter(e => e.status === 'broken-ref').length, + }; + }); + + filteredEntries = computed(() => { + let result = this.entries(); + const query = this.searchQuery().toLowerCase().trim(); + + if (this.hideResolved()) { + result = result.filter(e => e.status !== 'resolved'); + } + if (this.showOnlyMissing()) { + result = result.filter(e => e.status === 'missing'); + } + if (this.showOnlyBroken()) { + result = result.filter(e => e.status === 'broken-ref'); + } + if (this.showOnlyEmpty()) { + result = result.filter(e => e.status === 'empty'); + } + if (query) { + result = result.filter(e => + e.key.toLowerCase().includes(query) || + e.enValue.toLowerCase().includes(query) || + (e.localeValue?.toLowerCase().includes(query) ?? false) + ); + } + + return result; + }); + + ngOnInit() { + const lang = this.translocoService.getActiveLang(); + this.activeLang.set(lang); + this.loadData(lang); + + this.localizationService.getLocales().subscribe(langs => { + this.languages.set(langs); + }); + } + + onLocaleChange(lang: string) { + this.activeLang.set(lang); + this.loading.set(true); + this.loadData(lang); + } + + private loadData(lang: string) { + const en$ = this.http.get>('assets/langs/en.json'); + const locale$ = lang === 'en' + ? this.http.get>('assets/langs/en.json') + : this.http.get>(`assets/langs/${lang}.json`); + + forkJoin([en$, locale$]).subscribe({ + next: ([enData, localeData]) => { + const enFlat = this.flatten(enData); + const localeFlat = this.flatten(localeData); + this.buildEntries(enFlat, localeFlat); + this.loading.set(false); + }, + error: () => { + // If locale file doesn't exist, show English only + const en$ = this.http.get>('assets/langs/en.json'); + en$.subscribe(enData => { + const enFlat = this.flatten(enData); + this.buildEntries(enFlat, new Map()); + this.loading.set(false); + }); + } + }); + } + + private flatten(obj: Record, prefix = ''): Map { + const result = new Map(); + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null) { + for (const [k, v] of this.flatten(value, fullKey)) { + result.set(k, v); + } + } else if (typeof value === 'string') { + result.set(fullKey, value); + } + } + return result; + } + + private resolve(value: string, flatMap: Map, depth = 0): { resolved: string; chain: string[] } { + const chain: string[] = [value]; + if (depth > 10) return {resolved: value, chain}; + + const crossRefRe = /\{\{([\w-]+\.[\w-]+(?:\.[\w-]+)*)\}\}/g; + let current = value; + let match; + let iterations = 0; + + while ((match = crossRefRe.exec(current)) !== null && iterations < 10) { + const ref = match[1]; + const target = flatMap.get(ref); + if (target === undefined) { + chain.push(`{{${ref}}} [BROKEN]`); + break; + } + current = current.replace(match[0], target); + chain.push(current); + crossRefRe.lastIndex = 0; + iterations++; + } + + return {resolved: current, chain}; + } + + private isCrossRef(value: string): boolean { + return /\{\{[\w-]+\.[\w-]+(?:\.[\w-]+)*\}\}/.test(value); + } + + private buildEntries(enFlat: Map, localeFlat: Map) { + const entries: KeyEntry[] = []; + + for (const [key, enValue] of enFlat) { + const localeValue = localeFlat.get(key) ?? null; + const enResolution = this.resolve(enValue, enFlat); + const localeResolution = localeValue !== null + ? this.resolve(localeValue, localeFlat) + : {resolved: null, chain: []}; + + let status: KeyEntry['status'] = 'resolved'; + if (localeValue === null) { + status = 'missing'; + } else if (localeValue === '' && !this.isCrossRef(enValue)) { + status = 'empty'; + } else if ( + enResolution.chain.some(c => c.includes('[BROKEN]')) || + localeResolution.chain.some(c => c.includes('[BROKEN]')) + ) { + status = 'broken-ref'; + } + + entries.push({ + key, + enValue, + enResolved: enResolution.resolved, + enChain: enResolution.chain.length > 1 ? enResolution.chain : [], + localeValue, + localeResolved: localeResolution.resolved, + localeChain: localeResolution.chain.length > 1 ? localeResolution.chain : [], + status + }); + } + + this.entries.set(entries); + } + + getAvailableLocales(): string[] { + return ['ar','ca','cs','da','de','el','en','es','et','fi','fr','ga','hi','hr','hu', + 'id','it','ja','ko','lt','ms','nb_NO','nl','pl','pt','pt_BR','ru','sk','sl','sv', + 'ta','th','tr','uk','vi','zh_Hans','zh_Hant']; + } + + getBadgeClass(status: string): string { + switch (status) { + case 'missing': return 'bg-warning text-dark'; + case 'empty': return 'bg-info text-dark'; + case 'broken-ref': return 'bg-danger'; + default: return 'bg-secondary'; + } + } +} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 5f8048404..d82c83214 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -3923,6 +3923,31 @@ "bookmark-text-tab": "Text" }, + "locale-preview": { + "title": "Locale Preview", + "locale-label": "Locale", + "key-column": "Key", + "english-column": "English", + "current-locale-column": "Current Locale", + "stats-total": "Total Keys", + "stats-resolved": "Resolved", + "stats-missing": "Missing", + "stats-empty": "Empty", + "stats-broken": "Broken Refs", + "filter-hide-resolved": "Hide Resolved", + "filter-show-missing": "Show Only Missing", + "filter-show-empty": "Show Only Empty", + "filter-show-broken": "Show Only Broken Refs", + "search-placeholder": "Search keys or values…", + "showing-count": "Showing {{count}} of {{total}} keys", + "loading": "{{common.loading}}", + "missing-label": "Missing", + "empty-label": "Empty", + "broken-ref-label": "Broken Ref", + "not-present": "Not present in locale", + "empty-string": "Empty string" + }, + "common": { "reset-to-default": "Reset to Default", "close": "Close", diff --git a/UI/Web/src/main.ts b/UI/Web/src/main.ts index 829950e35..5ad23a19e 100644 --- a/UI/Web/src/main.ts +++ b/UI/Web/src/main.ts @@ -66,7 +66,7 @@ function transformLanguageCodes(arr: Array) { } // All Languages Kavita will support: http://www.lingoes.net/en/translator/langcode.htm -const languageCodes = [ +export const languageCodes = [ 'af', 'af_ZA', 'ar', 'ar_AE', 'ar_BH', 'ar_DZ', 'ar_EG', 'ar_IQ', 'ar_JO', 'ar_KW', 'ar_LB', 'ar_LY', 'ar_MA', 'ar_OM', 'ar_QA', 'ar_SA', 'ar_SY', 'ar_TN', 'ar_YE', 'az', 'az_AZ', 'az_AZ', 'be', 'be_BY', 'bg', 'bg_BG', 'bs_BA', 'ca', 'ca_ES', 'cs',