Added some localization scripts and a preview component to help with locales.

This commit is contained in:
Joseph Milazzo 2026-03-22 10:08:29 -05:00
parent bfe027ec1d
commit 9f9bc56247
11 changed files with 794 additions and 10 deletions

1
UI/Web/.gitignore vendored
View File

@ -4,3 +4,4 @@ playwright-report/
i18n-cache-busting.json
e2e-tests/environments/environment.local.ts
dead-i18n-keys.json
i18n-audit-report.json

View File

@ -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
View 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();

View File

@ -0,0 +1 @@
[]

View File

@ -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": {

View File

@ -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',

View 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>

View 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;
}
}

View 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';
}
}
}

View File

@ -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",

View File

@ -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',