mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-04-26 19:09:48 -04:00
Added some localization scripts and a preview component to help with locales.
This commit is contained in:
parent
bfe027ec1d
commit
9f9bc56247
1
UI/Web/.gitignore
vendored
1
UI/Web/.gitignore
vendored
@ -4,3 +4,4 @@ playwright-report/
|
||||
i18n-cache-busting.json
|
||||
e2e-tests/environments/environment.local.ts
|
||||
dead-i18n-keys.json
|
||||
i18n-audit-report.json
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
359
UI/Web/audit-i18n.js
Normal file
359
UI/Web/audit-i18n.js
Normal file
@ -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();
|
||||
1
UI/Web/i18n-dynamic-keys.json
Normal file
1
UI/Web/i18n-dynamic-keys.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -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": {
|
||||
|
||||
@ -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',
|
||||
|
||||
151
UI/Web/src/app/locale-preview/locale-preview.component.html
Normal file
151
UI/Web/src/app/locale-preview/locale-preview.component.html
Normal file
@ -0,0 +1,151 @@
|
||||
<ng-container *transloco="let t; prefix: 'locale-preview'">
|
||||
<div class="container-fluid mt-3">
|
||||
<h2>{{t('title')}}</h2>
|
||||
|
||||
<!-- Locale selector -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="locale-select">{{t('locale-label')}}</label>
|
||||
<select class="form-select" id="locale-select"
|
||||
[ngModel]="activeLang()"
|
||||
(ngModelChange)="onLocaleChange($event)">
|
||||
@for (locale of languages(); track locale) {
|
||||
<option [value]="locale.fileName">{{locale.renderName}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-auto">
|
||||
<span class="badge text-bg-secondary">{{t('stats-total')}}: {{stats().total | number}}</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge text-bg-success">{{t('stats-resolved')}}: {{stats().resolved | number}}</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge text-bg-warning">{{t('stats-missing')}}: {{stats().missing | number}}</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge text-bg-info">{{t('stats-empty')}}: {{stats().empty | number}}</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge text-bg-danger">{{t('stats-broken')}}: {{stats().broken | number}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row mb-3 g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" class="form-control" [placeholder]="t('search-placeholder')"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event)">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="hide-resolved"
|
||||
[ngModel]="hideResolved()"
|
||||
(ngModelChange)="hideResolved.set($event)">
|
||||
<label class="form-check-label" for="hide-resolved">{{t('filter-hide-resolved')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="show-missing"
|
||||
[ngModel]="showOnlyMissing()"
|
||||
(ngModelChange)="showOnlyMissing.set($event)">
|
||||
<label class="form-check-label" for="show-missing">{{t('filter-show-missing')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="show-empty"
|
||||
[ngModel]="showOnlyEmpty()"
|
||||
(ngModelChange)="showOnlyEmpty.set($event)">
|
||||
<label class="form-check-label" for="show-empty">{{t('filter-show-empty')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="show-broken"
|
||||
[ngModel]="showOnlyBroken()"
|
||||
(ngModelChange)="showOnlyBroken.set($event)">
|
||||
<label class="form-check-label" for="show-broken">{{t('filter-show-broken')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-body-secondary">
|
||||
{{t('showing-count', {count: filteredEntries().length, total: stats().total})}}
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="d-flex justify-content-center my-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered align-top">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th class="col-key">{{t('key-column')}}</th>
|
||||
<th class="col-value">{{t('english-column')}}</th>
|
||||
<th class="col-value">{{t('current-locale-column')}} ({{activeLang()}})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of filteredEntries(); track entry.key) {
|
||||
<tr>
|
||||
<td class="key-cell">
|
||||
<code>{{entry.key}}</code>
|
||||
@if (entry.status !== 'resolved') {
|
||||
<span class="badge ms-1" [class]="getBadgeClass(entry.status)">
|
||||
{{t(entry.status + '-label')}}
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@if (entry.enChain.length > 0) {
|
||||
<div class="ref-chain mt-1">
|
||||
@for (step of entry.enChain; track $index) {
|
||||
@if (!$first) {
|
||||
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
|
||||
}
|
||||
<code class="ref-step">{{step}}</code>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<span>{{entry.enResolved}}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.localeValue === null) {
|
||||
<span class="text-body-secondary fst-italic">{{t('not-present')}}</span>
|
||||
} @else if (entry.localeValue === '') {
|
||||
<span class="text-body-secondary fst-italic">{{t('empty-string')}}</span>
|
||||
} @else {
|
||||
<span>{{entry.localeResolved}}</span>
|
||||
@if (entry.localeChain.length > 0) {
|
||||
<div class="ref-chain mt-1">
|
||||
@for (step of entry.localeChain; track $index) {
|
||||
@if (!$first) {
|
||||
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
|
||||
}
|
||||
<code class="ref-step">{{step}}</code>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
22
UI/Web/src/app/locale-preview/locale-preview.component.scss
Normal file
22
UI/Web/src/app/locale-preview/locale-preview.component.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
218
UI/Web/src/app/locale-preview/locale-preview.component.ts
Normal file
218
UI/Web/src/app/locale-preview/locale-preview.component.ts
Normal file
@ -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<KavitaLocale[]>([]);
|
||||
entries = signal<KeyEntry[]>([]);
|
||||
activeLang = signal<string>('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<Record<string, any>>('assets/langs/en.json');
|
||||
const locale$ = lang === 'en'
|
||||
? this.http.get<Record<string, any>>('assets/langs/en.json')
|
||||
: this.http.get<Record<string, any>>(`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<Record<string, any>>('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<string, any>, prefix = ''): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
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<string, string>, 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<string, string>, localeFlat: Map<string, string>) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -66,7 +66,7 @@ function transformLanguageCodes(arr: Array<string>) {
|
||||
}
|
||||
|
||||
// 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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user