diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 32aeb884a..6e67e1af9 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -17,6 +17,7 @@ npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update) npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update) npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips) +npm run lint:i18n-keys # every translation key src/ names exists in shared en (CI gate; --unused lists en keys nothing reaches) npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report) npm run shots # Playwright screenshot project (shots:promote to accept) @@ -63,7 +64,7 @@ The offline core is flagship work surrounded by a periphery that ignores it. New ## Big-picture pieces - **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other. -- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. +- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, ``, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template key passes when its fixed parts match an en key, or through `DYNAMIC_ALLOWED` with a reason. - **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below. - **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill. - **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there. diff --git a/client/package.json b/client/package.json index 41266417c..f9ae1b0f2 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "lint:layers": "node scripts/layer-lint.mjs", "lint:offline": "node scripts/offline-lint.mjs", "lint:skips": "node scripts/skip-lint.mjs", + "lint:i18n-keys": "node scripts/i18n-keys.mjs", "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", "theme:lint:strict": "node scripts/theme-lint.mjs --strict", diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs new file mode 100644 index 000000000..a1bbfcfe1 --- /dev/null +++ b/client/scripts/i18n-keys.mjs @@ -0,0 +1,240 @@ +/* + * lint:i18n-keys: every translation key the client names exists in en. + * + * `t()` takes any string and returns the key itself when en has no such + * entry, so a typo or a key deleted from the locales renders as raw + * "budget.addCategory" text and nothing fails. Locale parity cannot see it: + * it compares the locales with en, not en with the code. This check reads + * src/ (tests aside) and resolves what it finds against shared/src/i18n/en: + * + * t('x.y'), tHtml('x.y'), tr('x.y') every string literal in the first + * argument, so both sides of a + * `cond ? 'a.b' : 'c.d'` are checked + * the markup variant + * translateApiError(t, err, 'x.y') the fallback key + * labelKey: 'x.y', titleKey="x.y" any property or prop named *Key that + * holds a dotted key, the tables + * components feed to t() later + * const guideKey = (id) => `help.${id}` a key builder (help/registry.ts): + * a template returned by a const + * named *Key, read as a template key + * + * A key exists when en declares it, or declares `key.other` (a plural group + * whose general form is spelled out). A template literal key such as + * t(`budget.category.${id}`), or a prefix with the rest appended + * (t('costs.filter.' + f)), cannot be resolved; it passes when its fixed + * parts match at least one en key, and otherwise only through DYNAMIC_ALLOWED. + * A key held in a variable (`t(opt.label)`) is out of reach unless the table + * names it in a *Key property. + * + * npm run lint:i18n-keys check (CI) + * npm run lint:i18n-keys -- --unused also list en keys no literal or pattern reaches + * + * The unused list is information, not a gate: keys reached through a + * variable (`t(item.labelKey)` with the table elsewhere, server-sent error + * keys) look unused here. + */ +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +// Plain paths rather than `new URL(..., import.meta.url)`: under the test runner's DOM environment URL is +// jsdom's, which fileURLToPath refuses. +const HERE = dirname(fileURLToPath(import.meta.url)) +const SRC = join(HERE, '..', 'src') +const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts') + +/** + * Template literal keys whose fixed parts match no en key, each with the + * reason it still resolves and the keys it resolves to, which must exist. + * Keep this short: every entry is a key the check cannot follow. + */ +export const DYNAMIC_ALLOWED = [ + { + template: '${opt.label}Hint', + because: + 'TripShareDialog appends Hint to each SHARE_OPTIONS label (useTripShare.ts); share.optTravelOnlyHint ' + + 'and share.optHideImagesHint exist, and the template has no fixed part to match them by', + resolves: ['share.optTravelOnlyHint', 'share.optHideImagesHint'], + }, +] + +const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/ +const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/ +const CALL_RE = /(?]*?\bhtml=(["'])([^"'\n]+)\1/g +const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g +const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g +const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) walk(path, files) + else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path) + } + return files +} + +const lineAt = (text, index) => text.slice(0, index).split('\n').length + +/** + * The source of the first argument of the call whose `(` sits at `open`: + * everything up to the top-level comma or the closing parenthesis, with + * strings, template literals and nested brackets skipped as units. + */ +export function firstArgument(text, open) { + let depth = 0 + for (let i = open + 1; i < text.length; i++) { + const c = text[i] + if (c === "'" || c === '"' || c === '`') { + for (i++; i < text.length && text[i] !== c; i++) { + if (text[i] === '\\') i++ + else if (c === '`' && text[i] === '$' && text[i + 1] === '{') { + // Skip the interpolation, counting its braces. + let braces = 0 + for (; i < text.length; i++) { + if (text[i] === '{') braces++ + else if (text[i] === '}' && --braces === 0) break + } + } + } + continue + } + if (c === '(' || c === '[' || c === '{') depth++ + else if (c === ')' || c === ']' || c === '}') { + if (depth === 0) return text.slice(open + 1, i) + depth-- + } else if (c === ',' && depth === 0) return text.slice(open + 1, i) + } + return text.slice(open + 1) +} + +/** The quoted literals and template literals of an argument's source. */ +export function literalsIn(arg) { + const quoted = [] + const templates = [] + for (const m of arg.matchAll(/(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g)) { + if (m[3] !== undefined) templates.push(m[3]) + else quoted.push(m[2]) + } + return { quoted, templates } +} + +/** A template literal's key pattern: its fixed parts, anything in between. Null when it has no fixed dotted prefix. */ +export function templatePattern(template) { + const parts = template.split(/\$\{[^}]*\}/) + if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null + const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${parts.map(escape).join('.+')}$`) +} + +/** Every key reference in one file's source. */ +export function scanSource(text, file = '') { + const literal = [] + const dynamic = [] + for (const m of text.matchAll(CALL_RE)) { + const open = m.index + m[0].length - 1 + const { quoted, templates } = literalsIn(firstArgument(text, open)) + const line = lineAt(text, m.index) + for (const key of quoted) { + if (KEY_RE.test(key)) literal.push({ file, line, key }) + // t('costs.filter.' + f): a prefix with the rest appended, read like a template. + else if (PREFIX_RE.test(key)) dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) }) + } + for (const template of templates) { + if (!template.includes('${')) { + if (KEY_RE.test(template)) literal.push({ file, line, key: template }) + continue + } + dynamic.push({ file, line, template, pattern: templatePattern(template) }) + } + } + for (const m of text.matchAll(KEY_BUILDER_RE)) { + // Builders of other strings (cache keys) have no fixed dotted prefix and are not translation keys. + const pattern = templatePattern(m[1]) + if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern }) + } + for (const re of [HTML_PROP_RE, API_ERROR_RE, KEY_PROP_RE]) { + for (const m of text.matchAll(re)) { + if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] }) + } + } + return { literal, dynamic } +} + +/** en's keys, read from the locale sources with the shared parity tooling's reader. */ +export async function readEnKeys() { + const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href) + const keys = new Set() + for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) keys.add(key) + if (keys.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken') + return keys +} + +export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`) + +/** + * The verdict over a scan: literal keys en lacks, template keys no en key + * matches (unless allowed), allow-list entries nothing uses any more, and + * the en keys nothing reaches. + */ +export function evaluate(scan, enKeys, allowed = DYNAMIC_ALLOWED) { + const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key)) + const allowedTemplates = new Set(allowed.map((a) => a.template)) + const unmatched = scan.dynamic.filter( + ({ template, pattern }) => + !allowedTemplates.has(template) && !(pattern && [...enKeys].some((k) => pattern.test(k))), + ) + const usedTemplates = new Set(scan.dynamic.map((d) => d.template)) + const stale = allowed.filter((a) => !usedTemplates.has(a.template) || a.resolves.some((k) => !hasKey(enKeys, k))) + const reached = new Set([...scan.literal.map((l) => l.key), ...allowed.flatMap((a) => a.resolves)]) + const patterns = scan.dynamic.map((d) => d.pattern).filter(Boolean) + const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '') + const unused = [...enKeys].filter( + (k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k))), + ) + return { missing, unmatched, stale, unused } +} + +export function scanTree(root = SRC) { + if (!existsSync(root)) throw new Error(`${root} does not exist`) + const scan = { literal: [], dynamic: [] } + for (const path of walk(root)) { + const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/')) + scan.literal.push(...found.literal) + scan.dynamic.push(...found.dynamic) + } + if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`) + return scan +} + +// Compared by real path, so the check still runs when the script is started through a symlink. +const isCli = + Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +if (isCli) { + try { + const scan = scanTree() + const enKeys = await readEnKeys() + const { missing, unmatched, stale, unused } = evaluate(scan, enKeys) + for (const { file, line, key } of missing) { + console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`) + } + for (const { file, line, template } of unmatched) { + console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`) + } + for (const { template } of stale) { + console.error(`FAIL DYNAMIC_ALLOWED lists \`${template}\`, which no file uses any more: remove the entry`) + } + if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`) + console.log( + `i18n keys: ${new Set(scan.literal.map((l) => l.key)).size} literal and ${scan.dynamic.length} template ` + + `reference(s) checked against ${enKeys.size} en keys; ${unused.length} en key(s) reached by neither ` + + '(information, --unused lists them)', + ) + if (missing.length || unmatched.length || stale.length) process.exit(1) + } catch (err) { + console.error(`FAIL ${err.message}`) + process.exit(1) + } +} diff --git a/client/tests/unit/i18n/i18nKeysScript.test.ts b/client/tests/unit/i18n/i18nKeysScript.test.ts new file mode 100644 index 000000000..b240d3180 --- /dev/null +++ b/client/tests/unit/i18n/i18nKeysScript.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DYNAMIC_ALLOWED, + evaluate, + firstArgument, + readEnKeys, + scanSource, + scanTree, + templatePattern, +} from '../../../scripts/i18n-keys.mjs' + +// FE-I18N-KEYS-001 to FE-I18N-KEYS-010: the client key check (scripts/i18n-keys.mjs). + +const EN = new Set(['budget.title', 'places.count', 'places.count.one', 'trips.total.other', 'trips.total.one', 'costs.filter.all']) + +describe('i18n key check', () => { + it('FE-I18N-KEYS-001: finds literal keys in t, tHtml, tr, TransHtml, translateApiError and *Key props', () => { + const { literal } = scanSource([ + "t('budget.title')", + 'props.t("places.count", { count })', + "tHtml('a.html')", + "tr('pdf.mapTitle')", + '', + "translateApiError(t, err, 'files.uploadError')", + "const tabs = [{ labelKey: 'tabs.one' }]", + '', + ].join('\n')) + expect(literal.map((l) => l.key)).toEqual([ + 'budget.title', 'places.count', 'a.html', 'pdf.mapTitle', 'journey.text', 'files.uploadError', 'tabs.one', 'hint.title', + ]) + }) + + it('FE-I18N-KEYS-002: checks both sides of a conditional key and ignores later arguments', () => { + const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })") + expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed']) + expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'") + }) + + it('FE-I18N-KEYS-003: reads templates, appended prefixes and key builders as patterns', () => { + const { literal, dynamic } = scanSource([ + 't(`costs.filter.${f}`)', + "t('costs.filter.' + f)", + 't(`plain.key`)', + 'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`', + 'const cacheKey = (id: number): string => `${id} x`', + "const other = notATranslation('x.y')", + ].join('\n')) + expect(literal.map((l) => l.key)).toEqual(['plain.key']) + expect(dynamic.map((d) => d.template)).toEqual(['costs.filter.${f}', 'costs.filter.${…}', 'help.guide.${id}.title']) + expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true) + expect(templatePattern('${opt.label}Hint')).toBeNull() + }) + + it('FE-I18N-KEYS-004: fails a literal key en lacks and accepts a plural group by its general form', () => { + const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })") + const { missing } = evaluate(scan, EN, []) + expect(missing.map((m) => m.key)).toEqual(['budget.addCategory']) + }) + + it('FE-I18N-KEYS-005: fails a template no en key matches unless it is allowed with the keys it resolves to', () => { + const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)') + expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint']) + const allowed = [{ template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }] + const result = evaluate(scan, EN, allowed) + expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']) + expect(result.stale).toEqual([]) + }) + + it('FE-I18N-KEYS-006: refuses an allow-list entry nothing uses or whose keys en lacks', () => { + const scan = scanSource('t(`${opt.label}Hint`)') + const unused = [{ template: '${gone}', because: 'test', resolves: ['budget.title'] }] + expect(evaluate(scan, EN, unused).stale).toEqual(unused) + const wrong = [{ template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }] + expect(evaluate(scan, EN, wrong).stale).toEqual(wrong) + }) + + it('FE-I18N-KEYS-007: reports en keys neither a literal nor a pattern reaches', () => { + const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)") + expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one']) + }) + + it('FE-I18N-KEYS-008: every allow-list entry carries a reason and the keys it resolves to', () => { + for (const entry of DYNAMIC_ALLOWED) { + expect(entry.because.length).toBeGreaterThan(20) + expect(entry.resolves.length).toBeGreaterThan(0) + } + }) + + it('FE-I18N-KEYS-009: fails closed on a missing source tree or one without any key', () => { + expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/) + const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-')) + try { + expect(() => scanTree(empty)).toThrow(/scanner is broken/) + } finally { + rmSync(empty, { recursive: true, force: true }) + } + }) + + it('FE-I18N-KEYS-010: the client source names no key en lacks today', async () => { + const enKeys = await readEnKeys() + const { missing, unmatched, stale } = evaluate(scanTree(), enKeys) + expect(missing).toEqual([]) + expect(unmatched).toEqual([]) + expect(stale).toEqual([]) + }) +}) diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md index 300a0605b..df8ad7dd7 100644 --- a/shared/CLAUDE.md +++ b/shared/CLAUDE.md @@ -50,6 +50,7 @@ The parity rule governs **existing** routes; it is not a license to mint new deb - **Every locale gets a real translation — an English placeholder is not acceptable.** Parity only checks that the key exists, so copying the `en` string passes CI while shipping English to those users. Write native phrasing with the locale's own punctuation and the same `{placeholders}` as `en`; if you genuinely cannot translate one, say so instead of filling it with English. - **Count-bearing strings use plural forms, never `count === 1` in the caller.** The key holds the general form (the CLDR "other" category) and `key.zero|one|two|few|many` hold the forms the language's `Intl.PluralRules` selects for whole numbers (`plural.ts`); `t(key, { count })` (or `{ n }`) picks one. Pass the count as a number. Russian needs `.one`, `.few` and `.many`, Arabic all five, Japanese none. `i18n:parity` checks that every locale has exactly the forms its language reaches with whole numbers and none it never selects. A form may spell the number out (Arabic "ملف واحد") only where its category is that one number; French `one` also covers 0 and Russian `one` covers 21, so they keep `{count}`. - **Further i18n specs run in `npm test`** and fail changes that parity lets through: placeholder parity (every `{placeholder}` in an `en` string must appear in each translation), no `en` string may call TREK "self-hosted" (the same build runs on managed installs; exemptions live in the spec), and a wording regression test for plugin permission descriptions (currently one string — add a case there when a permission's real behavior changes). None of these are part of `i18n:parity`, which checks key sets and plural forms only. +- **en holds every key the client names.** `npm run lint:i18n-keys` in `client/` resolves the keys `src/` passes to `t()` and its helpers against `en/`, read through `scripts/i18n-catalog.mjs`. That reader takes the tables as text: a value must be one string literal on the key's line or the next, and anything else (a concatenation, a template literal) is an error rather than a skipped key. Its spec uses the fixtures in `scripts/fixtures/`. ## Sanitization (`src/sanitize/sanitize.ts`) diff --git a/shared/scripts/fixtures/i18n/de/a.ts b/shared/scripts/fixtures/i18n/de/a.ts new file mode 100644 index 000000000..161ef0d78 --- /dev/null +++ b/shared/scripts/fixtures/i18n/de/a.ts @@ -0,0 +1,14 @@ +// Fixture: two unmarked English copies (a.title, a.multi), one marked (a.lone). +const a = { + 'a.title': 'Trip settings', + 'a.count': '{count} Orte', + 'a.count.one': '{count} Ort', + 'a.lone': 'Remove {count} items', // en-fallback + 'a.day': 'Tag {n}', + 'a.brand': 'Google Maps', + 'a.code': 'GPX', + 'a.quote': 'Es ist hier', + 'a.multi': + 'A long sentence that wraps onto the next line', +}; +export default a; diff --git a/shared/scripts/fixtures/i18n/en/a.ts b/shared/scripts/fixtures/i18n/en/a.ts new file mode 100644 index 000000000..83affa1e6 --- /dev/null +++ b/shared/scripts/fixtures/i18n/en/a.ts @@ -0,0 +1,14 @@ +// Fixture for the i18n script specs: a canonical table. +const a = { + 'a.title': 'Trip settings', + 'a.count': '{count} places', + 'a.count.one': '{count} place', + 'a.lone': 'Remove {count} items', + 'a.day': 'Day {n}', + 'a.brand': 'Google Maps', + 'a.code': 'GPX', + 'a.quote': "It's here", + 'a.multi': + 'A long sentence that wraps onto the next line', +}; +export default a; diff --git a/shared/scripts/i18n-catalog.mjs b/shared/scripts/i18n-catalog.mjs new file mode 100644 index 000000000..bcb2a035c --- /dev/null +++ b/shared/scripts/i18n-catalog.mjs @@ -0,0 +1,103 @@ +/** + * Reads the locale tables as text, for the i18n scripts beside this file. + * + * Plain .mjs outside `src/` for the reason i18n-parity.mjs gives: shared's + * typecheck runs without @types/node, and the CI parity job runs these scripts + * with no install at all. The tables are parsed rather than imported so the + * checks stay synchronous and see the `// en-fallback` markers, which an + * import would strip. + * + * A value is one string literal, single or double quoted, on the key's line or + * the next one (the two shapes Prettier writes). Anything else after a key (a + * concatenation, a template literal, a call) is an error rather than a skipped + * key: a check that silently reads less than the file holds passes for the + * wrong reason. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const I18N_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n'); + +export const FALLBACK_MARKER = '// en-fallback'; + +/** A root given as a file URL (what a spec can build without node types) or a path. */ +export const asPath = (root) => (root instanceof URL ? fileURLToPath(root) : root); + +/** Every locale folder (externalNotifications is a barrel module, not a locale). */ +export function listLocales(root = I18N_ROOT) { + const dir = asPath(root); + return readdirSync(dir) + .filter((name) => statSync(join(dir, name)).isDirectory()) + .filter((name) => name !== 'externalNotifications'); +} + +/** The domain files of one locale, sorted. */ +export function listDomainFiles(locale, root = I18N_ROOT) { + return readdirSync(join(asPath(root), locale)) + .filter((f) => f.endsWith('.ts') && f !== 'index.ts') + .sort(); +} + +const KEY_LINE_RE = /^\s*'([a-z][a-zA-Z0-9.\-_:]*)'\s*:(.*)$/; + +const ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }; + +/** The value of a JS string literal body (between the quotes). */ +export function decodeLiteral(body) { + return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\r?\n|[\s\S])/g, (_, esc) => { + if (esc[0] === 'u' && esc.length > 1) return String.fromCodePoint(parseInt(esc.replace(/[u{}]/g, ''), 16)); + if (esc[0] === 'x' && esc.length === 3) return String.fromCharCode(parseInt(esc.slice(1), 16)); + if (esc === '\n' || esc === '\r\n') return ''; + return ESCAPES[esc] ?? esc; + }); +} + +/** Reads one quoted literal at the start of `text`; null when `text` does not start with one. */ +function readLiteral(text) { + const quote = text[0]; + if (quote !== "'" && quote !== '"') return null; + for (let i = 1; i < text.length; i++) { + if (text[i] === '\\') { + i++; + continue; + } + if (text[i] === '\n') return null; + if (text[i] === quote) return { value: decodeLiteral(text.slice(1, i)), rest: text.slice(i + 1) }; + } + return null; +} + +/** + * Every top-level entry of one table's source: key, value, whether the + * declaration carries the fallback marker, and the line it starts on. + * Throws with file and line on anything it cannot read. + */ +export function parseCatalog(source, label = 'catalogue') { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const entries = []; + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(KEY_LINE_RE); + if (!match) continue; + const [, key, after] = match; + let text = after.trim(); + let end = i; + if (text === '') { + end = i + 1; + text = (lines[end] ?? '').trim(); + } + const literal = readLiteral(text); + const tail = literal?.rest.trim() ?? ''; + if (!literal || !/^(,|\})?\s*(\/\/.*)?$/.test(tail)) { + throw new Error(`${label}:${i + 1}: '${key}' is not a single string literal`); + } + entries.push({ key, value: literal.value, marked: tail.includes(FALLBACK_MARKER), line: i + 1 }); + i = end; + } + return entries; +} + +/** The entries of `locale/file`, read from disk. */ +export function readCatalog(locale, file, root = I18N_ROOT) { + return parseCatalog(readFileSync(join(asPath(root), locale, file), 'utf8'), `${locale}/${file}`); +} diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index 6b4843b07..9dda87cfe 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'إنشاء فئة', 'budget.category': 'الفئة', 'budget.categoryName': 'اسم الفئة', + 'budget.addCategory': 'إضافة فئة', 'budget.table.name': 'الاسم', 'budget.table.total': 'الإجمالي', 'budget.table.persons': 'الأشخاص', diff --git a/shared/src/i18n/ar/common.ts b/shared/src/i18n/ar/common.ts index a0437246b..0f57ab6d3 100644 --- a/shared/src/i18n/ar/common.ts +++ b/shared/src/i18n/ar/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'إلغاء', 'common.clear': 'مسح', 'common.delete': 'حذف', + 'common.remove': 'إزالة', 'common.preview': 'معاينة', 'common.edit': 'تعديل', 'common.add': 'إضافة', diff --git a/shared/src/i18n/az/budget.ts b/shared/src/i18n/az/budget.ts index af9712794..40cb34705 100644 --- a/shared/src/i18n/az/budget.ts +++ b/shared/src/i18n/az/budget.ts @@ -12,6 +12,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kateqoriya yarat', 'budget.category': 'Kateqoriya', 'budget.categoryName': 'Kateqoriyanın adı', + 'budget.addCategory': 'Kateqoriya əlavə et', 'budget.table.name': 'Ad', 'budget.table.total': 'Ümumi', diff --git a/shared/src/i18n/az/common.ts b/shared/src/i18n/az/common.ts index 8c4b1f9ec..ba3dcdf44 100644 --- a/shared/src/i18n/az/common.ts +++ b/shared/src/i18n/az/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Ləğv et', 'common.clear': 'Təmizlə', 'common.delete': 'Sil', + 'common.remove': 'Sil', 'common.preview': 'Önizləmə', 'common.edit': 'Redaktə et', 'common.add': 'Əlavə et', diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 7f900deb1..8fc24cc45 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Criar categoria', 'budget.category': 'Categoria', 'budget.categoryName': 'Nome da categoria', + 'budget.addCategory': 'Adicionar categoria', 'budget.table.name': 'Nome', 'budget.table.total': 'Total', 'budget.table.persons': 'Pessoas', diff --git a/shared/src/i18n/br/common.ts b/shared/src/i18n/br/common.ts index a81b0b76f..d8e3eb391 100644 --- a/shared/src/i18n/br/common.ts +++ b/shared/src/i18n/br/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancelar', 'common.clear': 'Limpar', 'common.delete': 'Excluir', + 'common.remove': 'Remover', 'common.preview': 'Prévia', 'common.edit': 'Editar', 'common.add': 'Adicionar', diff --git a/shared/src/i18n/ca/budget.ts b/shared/src/i18n/ca/budget.ts index d9dc8d887..39655f0a3 100644 --- a/shared/src/i18n/ca/budget.ts +++ b/shared/src/i18n/ca/budget.ts @@ -9,6 +9,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Crea una categoria', 'budget.category': 'Categoria', 'budget.categoryName': 'Nom de la categoria', + 'budget.addCategory': 'Afegeix una categoria', 'budget.table.name': 'Nom', 'budget.table.total': 'Total', 'budget.table.persons': 'Persones', diff --git a/shared/src/i18n/ca/common.ts b/shared/src/i18n/ca/common.ts index dab02cd50..acfcf430c 100644 --- a/shared/src/i18n/ca/common.ts +++ b/shared/src/i18n/ca/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancel·lar', 'common.clear': 'Esborrar', 'common.delete': 'Eliminar', + 'common.remove': 'Elimina', 'common.preview': 'Vista prèvia', 'common.edit': 'Editar', 'common.add': 'Afegir', diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index 77b87fb55..b7de4453d 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Vytvořit kategorii', 'budget.category': 'Kategorie', 'budget.categoryName': 'Název kategorie', + 'budget.addCategory': 'Přidat kategorii', 'budget.table.name': 'Název', 'budget.table.total': 'Celkem', 'budget.table.persons': 'Osoby', diff --git a/shared/src/i18n/cs/common.ts b/shared/src/i18n/cs/common.ts index 9f692890a..07d4fe8bf 100644 --- a/shared/src/i18n/cs/common.ts +++ b/shared/src/i18n/cs/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Zrušit', 'common.clear': 'Vymazat', 'common.delete': 'Smazat', + 'common.remove': 'Odstranit', 'common.preview': 'Náhled', 'common.edit': 'Upravit', 'common.add': 'Přidat', diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index f0643a486..fd71b1cef 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kategorie erstellen', 'budget.category': 'Kategorie', 'budget.categoryName': 'Kategoriename', + 'budget.addCategory': 'Kategorie hinzufügen', 'budget.table.name': 'Name', 'budget.table.total': 'Gesamt', 'budget.table.persons': 'Personen', diff --git a/shared/src/i18n/de/common.ts b/shared/src/i18n/de/common.ts index f9d35366d..1db340ba2 100644 --- a/shared/src/i18n/de/common.ts +++ b/shared/src/i18n/de/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Abbrechen', 'common.clear': 'Löschen', 'common.delete': 'Löschen', + 'common.remove': 'Entfernen', 'common.preview': 'Vorschau', 'common.edit': 'Bearbeiten', 'common.add': 'Hinzufügen', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index c7deeed8f..9f23b3179 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Create Category', 'budget.category': 'Category', 'budget.categoryName': 'Category Name', + 'budget.addCategory': 'Add category', 'budget.table.name': 'Name', 'budget.table.total': 'Total', 'budget.table.persons': 'Persons', diff --git a/shared/src/i18n/en/common.ts b/shared/src/i18n/en/common.ts index 23d1c6aad..004fa17ff 100644 --- a/shared/src/i18n/en/common.ts +++ b/shared/src/i18n/en/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancel', 'common.clear': 'Clear', 'common.delete': 'Delete', + 'common.remove': 'Remove', 'common.preview': 'Preview', 'common.edit': 'Edit', 'common.add': 'Add', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 9747e8e10..99bced521 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Crear categoría', 'budget.category': 'Categoría', 'budget.categoryName': 'Nombre de la categoría', + 'budget.addCategory': 'Añadir categoría', 'budget.table.name': 'Nombre', 'budget.table.total': 'Total', 'budget.table.persons': 'Personas', diff --git a/shared/src/i18n/es/common.ts b/shared/src/i18n/es/common.ts index 2ba40ca78..f79e6b99b 100644 --- a/shared/src/i18n/es/common.ts +++ b/shared/src/i18n/es/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancelar', 'common.clear': 'Borrar', 'common.delete': 'Eliminar', + 'common.remove': 'Eliminar', 'common.preview': 'Vista previa', 'common.edit': 'Editar', 'common.add': 'Añadir', diff --git a/shared/src/i18n/et/budget.ts b/shared/src/i18n/et/budget.ts index 1d80ca82c..a632b1d45 100644 --- a/shared/src/i18n/et/budget.ts +++ b/shared/src/i18n/et/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Loo kategooria', 'budget.category': 'Kategooria', 'budget.categoryName': 'Kategooria nimi', + 'budget.addCategory': 'Lisa kategooria', 'budget.table.name': 'Nimi', 'budget.table.total': 'Kokku', 'budget.table.persons': 'Inimesi', diff --git a/shared/src/i18n/et/common.ts b/shared/src/i18n/et/common.ts index 3d1f34651..62708e0d8 100644 --- a/shared/src/i18n/et/common.ts +++ b/shared/src/i18n/et/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Tühista', 'common.clear': 'Tühjenda', 'common.delete': 'Kustuta', + 'common.remove': 'Eemalda', 'common.preview': 'Eelvaade', 'common.edit': 'Muuda', 'common.add': 'Lisa', diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index 6b57f6df6..65a07adca 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Créer une catégorie', 'budget.category': 'Catégorie', 'budget.categoryName': 'Nom de la catégorie', + 'budget.addCategory': 'Ajouter une catégorie', 'budget.table.name': 'Nom', 'budget.table.total': 'Total', 'budget.table.persons': 'Personnes', diff --git a/shared/src/i18n/fr/common.ts b/shared/src/i18n/fr/common.ts index 90fb4d3be..6e14e015f 100644 --- a/shared/src/i18n/fr/common.ts +++ b/shared/src/i18n/fr/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Annuler', 'common.clear': 'Effacer', 'common.delete': 'Supprimer', + 'common.remove': 'Supprimer', 'common.preview': 'Aperçu', 'common.edit': 'Modifier', 'common.add': 'Ajouter', diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index f4d13ea65..1ccc669fb 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Δημιουργία Κατηγορίας', 'budget.category': 'Κατηγορία', 'budget.categoryName': 'Όνομα Κατηγορίας', + 'budget.addCategory': 'Προσθήκη κατηγορίας', 'budget.table.name': 'Όνομα', 'budget.table.total': 'Σύνολο', 'budget.table.persons': 'Άτομα', diff --git a/shared/src/i18n/gr/common.ts b/shared/src/i18n/gr/common.ts index cf4782db5..d9c1f353e 100644 --- a/shared/src/i18n/gr/common.ts +++ b/shared/src/i18n/gr/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Ακύρωση', 'common.clear': 'Εκκαθάριση', 'common.delete': 'Διαγραφή', + 'common.remove': 'Αφαίρεση', 'common.preview': 'Προεπισκόπηση', 'common.edit': 'Επεξεργασία', 'common.add': 'Προσθήκη', diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index 459421679..4d6999e5a 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kategória létrehozása', 'budget.category': 'Kategória', 'budget.categoryName': 'Kategória neve', + 'budget.addCategory': 'Kategória hozzáadása', 'budget.table.name': 'Név', 'budget.table.total': 'Összesen', 'budget.table.persons': 'Személyek', diff --git a/shared/src/i18n/hu/common.ts b/shared/src/i18n/hu/common.ts index 43a6e09d8..877030f6c 100644 --- a/shared/src/i18n/hu/common.ts +++ b/shared/src/i18n/hu/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Mégse', 'common.clear': 'Törlés', 'common.delete': 'Törlés', + 'common.remove': 'Eltávolítás', 'common.preview': 'Előnézet', 'common.edit': 'Szerkesztés', 'common.add': 'Hozzáadás', diff --git a/shared/src/i18n/i18n-catalog.spec.ts b/shared/src/i18n/i18n-catalog.spec.ts new file mode 100644 index 000000000..83d9d9782 --- /dev/null +++ b/shared/src/i18n/i18n-catalog.spec.ts @@ -0,0 +1,58 @@ +// @ts-expect-error: plain .mjs script with no .d.ts; import as JS module. +import { listDomainFiles, listLocales, parseCatalog, readCatalog } from '../../scripts/i18n-catalog.mjs'; + +import { describe, it, expect } from 'vitest'; + +/** + * The reader the i18n scripts (parity, the untranslated ratchet, the client's + * key check) share. It reads the tables as text, so it sees the + * `// en-fallback` markers, and it refuses what it cannot read rather than + * skipping it. + */ +const ROOT = new URL('../../scripts/fixtures/i18n/', import.meta.url); + +type Entry = { key: string; value: string; marked: boolean; line: number }; + +describe('i18n catalogue reader', () => { + it('reads single-line, wrapped, double-quoted and escaped values with their markers', () => { + const entries = parseCatalog( + [ + " 'a.one': 'One',", + " 'a.two':", + " 'Two, wrapped', // en-fallback", + ` 'a.three': "It's",`, + " 'a.four': 'Don\\'t \\u00e9',", + ].join('\r\n'), + ); + expect(entries.map((e: Entry) => [e.key, e.value, e.marked])).toEqual([ + ['a.one', 'One', false], + ['a.two', 'Two, wrapped', true], + ['a.three', "It's", false], + ['a.four', "Don't é", false], + ]); + }); + + it('refuses a value that is not one string literal instead of skipping it', () => { + expect(() => parseCatalog(" 'a.one': 'One' + 'Two',", 'x.ts')).toThrow( + /x\.ts:1: 'a\.one' is not a single string literal/, + ); + expect(() => parseCatalog(" 'a.one': `One`,", 'x.ts')).toThrow(/not a single string literal/); + expect(() => parseCatalog(" 'a.one': 'unterminated,", 'x.ts')).toThrow(/not a single string literal/); + }); + + it('lists locales and domain files and reads a table from disk', () => { + expect(listLocales(ROOT).sort()).toEqual(['de', 'en']); + expect(listDomainFiles('en', ROOT)).toEqual(['a.ts']); + const de: Entry[] = readCatalog('de', 'a.ts', ROOT); + expect(de.find((e) => e.key === 'a.lone')).toMatchObject({ value: 'Remove {count} items', marked: true }); + expect(de.find((e) => e.key === 'a.multi')).toMatchObject({ + value: 'A long sentence that wraps onto the next line', + }); + }); + + it('reads every real locale table', () => { + for (const locale of listLocales()) { + for (const file of listDomainFiles(locale)) expect(() => readCatalog(locale, file)).not.toThrow(); + } + }); +}); diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index f23297c6c..6ada99ed4 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Buat Kategori', 'budget.category': 'Kategori', 'budget.categoryName': 'Nama Kategori', + 'budget.addCategory': 'Tambah kategori', 'budget.table.name': 'Nama', 'budget.table.total': 'Total', 'budget.table.persons': 'Orang', diff --git a/shared/src/i18n/id/common.ts b/shared/src/i18n/id/common.ts index eb0642d40..5cf8e179c 100644 --- a/shared/src/i18n/id/common.ts +++ b/shared/src/i18n/id/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Batal', 'common.clear': 'Hapus', 'common.delete': 'Hapus', + 'common.remove': 'Hapus', 'common.preview': 'Pratinjau', 'common.edit': 'Sunting', 'common.add': 'Tambah', diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index ef9ac2b02..726730c41 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Crea categoria', 'budget.category': 'Categoria', 'budget.categoryName': 'Nome categoria', + 'budget.addCategory': 'Aggiungi categoria', 'budget.table.name': 'Nome', 'budget.table.total': 'Totale', 'budget.table.persons': 'Persone', diff --git a/shared/src/i18n/it/common.ts b/shared/src/i18n/it/common.ts index a2ecf2858..4fd14f38a 100644 --- a/shared/src/i18n/it/common.ts +++ b/shared/src/i18n/it/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Annulla', 'common.clear': 'Cancella', 'common.delete': 'Elimina', + 'common.remove': 'Rimuovi', 'common.preview': 'Anteprima', 'common.edit': 'Modifica', 'common.add': 'Aggiungi', diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index b9cefcdad..f0f183980 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'カテゴリを作成', 'budget.category': 'カテゴリ', 'budget.categoryName': 'カテゴリ名', + 'budget.addCategory': 'カテゴリを追加', 'budget.table.name': '名前', 'budget.table.total': '合計', 'budget.table.persons': '人数', diff --git a/shared/src/i18n/ja/common.ts b/shared/src/i18n/ja/common.ts index dbd28f10c..22236a113 100644 --- a/shared/src/i18n/ja/common.ts +++ b/shared/src/i18n/ja/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'キャンセル', 'common.clear': 'クリア', 'common.delete': '削除', + 'common.remove': '削除', 'common.preview': 'プレビュー', 'common.edit': '編集', 'common.add': '追加', diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 3db01e063..58e378e0b 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': '카테고리 만들기', 'budget.category': '카테고리', 'budget.categoryName': '카테고리 이름', + 'budget.addCategory': '카테고리 추가', 'budget.table.name': '이름', 'budget.table.total': '합계', 'budget.table.persons': '인원', diff --git a/shared/src/i18n/ko/common.ts b/shared/src/i18n/ko/common.ts index b99a6b02f..0cebdb73f 100644 --- a/shared/src/i18n/ko/common.ts +++ b/shared/src/i18n/ko/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': '취소', 'common.clear': '지우기', 'common.delete': '삭제', + 'common.remove': '제거', 'common.preview': '미리보기', 'common.edit': '편집', 'common.add': '추가', diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index c6cbb1ef1..9f99e795e 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Categorie aanmaken', 'budget.category': 'Categorie', 'budget.categoryName': 'Categorienaam', + 'budget.addCategory': 'Categorie toevoegen', 'budget.table.name': 'Naam', 'budget.table.total': 'Totaal', 'budget.table.persons': 'Personen', diff --git a/shared/src/i18n/nl/common.ts b/shared/src/i18n/nl/common.ts index b09128a8a..172cd8912 100644 --- a/shared/src/i18n/nl/common.ts +++ b/shared/src/i18n/nl/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Annuleren', 'common.clear': 'Wissen', 'common.delete': 'Verwijderen', + 'common.remove': 'Verwijderen', 'common.preview': 'Voorbeeld', 'common.edit': 'Bewerken', 'common.add': 'Toevoegen', diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index 5894e8dc2..ba35955c2 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -10,6 +10,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Utwórz kategorię', 'budget.category': 'Kategoria', 'budget.categoryName': 'Nazwa kategorii', + 'budget.addCategory': 'Dodaj kategorię', 'budget.table.name': 'Nazwa', 'budget.table.total': 'Łącznie', 'budget.table.persons': 'Osoby', diff --git a/shared/src/i18n/pl/common.ts b/shared/src/i18n/pl/common.ts index f50dc479a..87f6dae65 100644 --- a/shared/src/i18n/pl/common.ts +++ b/shared/src/i18n/pl/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Anuluj', 'common.clear': 'Wyczyść', 'common.delete': 'Usuń', + 'common.remove': 'Usuń', 'common.preview': 'Podgląd', 'common.edit': 'Edytuj', 'common.add': 'Dodaj', diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index 2d32cb5fb..02fe11758 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Создать категорию', 'budget.category': 'Категория', 'budget.categoryName': 'Название категории', + 'budget.addCategory': 'Добавить категорию', 'budget.table.name': 'Название', 'budget.table.total': 'Итого', 'budget.table.persons': 'Человек', diff --git a/shared/src/i18n/ru/common.ts b/shared/src/i18n/ru/common.ts index ff8beaf7b..7db600594 100644 --- a/shared/src/i18n/ru/common.ts +++ b/shared/src/i18n/ru/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Отмена', 'common.clear': 'Очистить', 'common.delete': 'Удалить', + 'common.remove': 'Удалить', 'common.preview': 'Предпросмотр', 'common.edit': 'Редактировать', 'common.add': 'Добавить', diff --git a/shared/src/i18n/sk/budget.ts b/shared/src/i18n/sk/budget.ts index a2f1d7b49..680363bdc 100644 --- a/shared/src/i18n/sk/budget.ts +++ b/shared/src/i18n/sk/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Vytvoriť kategóriu', 'budget.category': 'Kategória', 'budget.categoryName': 'Názov kategórie', + 'budget.addCategory': 'Pridať kategóriu', 'budget.table.name': 'Názov', 'budget.table.total': 'Spolu', 'budget.table.persons': 'Osoby', diff --git a/shared/src/i18n/sk/common.ts b/shared/src/i18n/sk/common.ts index 2d93b96a1..2a0b5b284 100644 --- a/shared/src/i18n/sk/common.ts +++ b/shared/src/i18n/sk/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Zrušiť', 'common.clear': 'Vymazať', 'common.delete': 'Odstrániť', + 'common.remove': 'Odstrániť', 'common.preview': 'Náhľad', 'common.edit': 'Upraviť', 'common.add': 'Pridať', diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index df6ae4578..89b9c9426 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Skapa kategori', 'budget.category': 'Kategori', 'budget.categoryName': 'Kategorinamn', + 'budget.addCategory': 'Lägg till kategori', 'budget.table.name': 'Namn', 'budget.table.total': 'Totalt', 'budget.table.persons': 'Personer', diff --git a/shared/src/i18n/sv/common.ts b/shared/src/i18n/sv/common.ts index 57efdcff2..987645a2c 100644 --- a/shared/src/i18n/sv/common.ts +++ b/shared/src/i18n/sv/common.ts @@ -19,6 +19,7 @@ const common: TranslationStrings = { 'common.cancel': 'Avbryt', 'common.clear': 'Rensa', 'common.delete': 'Ta bort', + 'common.remove': 'Ta bort', 'common.preview': 'Förhandsvisning', 'common.edit': 'Redigera', 'common.add': 'Lägg till', diff --git a/shared/src/i18n/th/budget.ts b/shared/src/i18n/th/budget.ts index 75fadc1be..a1e9a9992 100644 --- a/shared/src/i18n/th/budget.ts +++ b/shared/src/i18n/th/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'สร้างหมวดหมู่', 'budget.category': 'หมวดหมู่', 'budget.categoryName': 'ชื่อหมวดหมู่', + 'budget.addCategory': 'เพิ่มหมวดหมู่', 'budget.table.name': 'ชื่อ', 'budget.table.total': 'รวม', 'budget.table.persons': 'บุคคล', diff --git a/shared/src/i18n/th/common.ts b/shared/src/i18n/th/common.ts index bfb50ad26..95a2230d2 100644 --- a/shared/src/i18n/th/common.ts +++ b/shared/src/i18n/th/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'ยกเลิก', 'common.clear': 'ล้าง', 'common.delete': 'ลบ', + 'common.remove': 'ลบ', 'common.preview': 'ดูตัวอย่าง', 'common.edit': 'แก้ไข', 'common.add': 'เพิ่ม', diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index ab937e569..d6f20c73d 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kategori Oluştur', 'budget.category': 'Kategori', 'budget.categoryName': 'Kategori Adı', + 'budget.addCategory': 'Kategori ekle', 'budget.table.name': 'İsim', 'budget.table.total': 'Toplam', 'budget.table.persons': 'Kişiler', diff --git a/shared/src/i18n/tr/common.ts b/shared/src/i18n/tr/common.ts index aaee60a54..8b527d6bb 100644 --- a/shared/src/i18n/tr/common.ts +++ b/shared/src/i18n/tr/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'İptal', 'common.clear': 'Temizle', 'common.delete': 'Sil', + 'common.remove': 'Kaldır', 'common.preview': 'Önizleme', 'common.edit': 'Düzenle', 'common.add': 'Ekle', diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 7b771fa6a..2f0041e00 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Створити категорію', 'budget.category': 'Категорія', 'budget.categoryName': 'Назва категорії', + 'budget.addCategory': 'Додати категорію', 'budget.table.name': 'Назва', 'budget.table.total': 'Всього', 'budget.table.persons': 'Осіб', diff --git a/shared/src/i18n/uk/common.ts b/shared/src/i18n/uk/common.ts index 3474b0c92..fdf6f018c 100644 --- a/shared/src/i18n/uk/common.ts +++ b/shared/src/i18n/uk/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Скасувати', 'common.clear': 'Очистити', 'common.delete': 'Видалити', + 'common.remove': 'Видалити', 'common.preview': 'Перегляд', 'common.edit': 'Редагувати', 'common.add': 'Додати', diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index d069b8167..f9473700d 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Tạo danh mục', 'budget.category': 'Loại', 'budget.categoryName': 'Tên danh mục', + 'budget.addCategory': 'Thêm danh mục', 'budget.table.name': 'Tên', 'budget.table.total': 'Tổng cộng', 'budget.table.persons': 'Người', diff --git a/shared/src/i18n/vi/common.ts b/shared/src/i18n/vi/common.ts index 78523743d..ad42a6759 100644 --- a/shared/src/i18n/vi/common.ts +++ b/shared/src/i18n/vi/common.ts @@ -19,6 +19,7 @@ const common: TranslationStrings = { 'common.cancel': 'Hủy', 'common.clear': 'Xóa', 'common.delete': 'Xóa bỏ', + 'common.remove': 'Gỡ bỏ', 'common.preview': 'Xem trước', 'common.edit': 'Chỉnh sửa', 'common.add': 'Thêm', diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index d99f83747..58484d09c 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': '建立分類', 'budget.category': '分類', 'budget.categoryName': '分類名稱', + 'budget.addCategory': '新增分類', 'budget.table.name': '名稱', 'budget.table.total': '合計', 'budget.table.persons': '人數', diff --git a/shared/src/i18n/zh-TW/common.ts b/shared/src/i18n/zh-TW/common.ts index 0d7cda513..1834d5513 100644 --- a/shared/src/i18n/zh-TW/common.ts +++ b/shared/src/i18n/zh-TW/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': '取消', 'common.clear': '清除', 'common.delete': '刪除', + 'common.remove': '刪除', 'common.preview': '預覽', 'common.edit': '編輯', 'common.add': '新增', diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index c49d1385e..afa1b93cc 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': '创建分类', 'budget.category': '分类', 'budget.categoryName': '分类名称', + 'budget.addCategory': '添加分类', 'budget.table.name': '名称', 'budget.table.total': '合计', 'budget.table.persons': '人数', diff --git a/shared/src/i18n/zh/common.ts b/shared/src/i18n/zh/common.ts index 3aeaf542e..8814437f5 100644 --- a/shared/src/i18n/zh/common.ts +++ b/shared/src/i18n/zh/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': '取消', 'common.clear': '清除', 'common.delete': '删除', + 'common.remove': '删除', 'common.preview': '预览', 'common.edit': '编辑', 'common.add': '添加',