feat(l10n): single-source localization pipeline

Add localization/ — one strings.json is the source of truth for every
user-facing string, and a Bun CLI generates the platform files:

  loc migrate   rebuild strings.json from existing platform files (one-time)
  loc validate  structural checks (plural `other`, arg refs, coverage)
  loc generate  emit .xcstrings (Apple) + per-language strings.xml (KMP)

Canonical `{name}` placeholders are converted to each platform's positional
printf tokens using declared arg types. Plurals use CLDR categories and emit
native forms: xcstrings plural variations and Compose <plurals> blocks.

Migration folds the KMP `transfer_file_count_one`/`_other` pair into a single
plural key, so `transferFileCountResource` now returns the plural resource and
callers resolve it with pluralStringResource.
This commit is contained in:
2026-07-20 10:15:28 +02:00
parent 9a3cfb55d0
commit cd6a2d66aa
19 changed files with 4882 additions and 1775 deletions

File diff suppressed because it is too large Load Diff

87
localization/README.md Normal file
View File

@@ -0,0 +1,87 @@
# Localization
Single source of truth for every user-facing string, in [`strings.json`](strings.json).
A Bun CLI generates the platform-native files from it:
| Target | Output | Notes |
| --- | --- | --- |
| `apple` | `apple/VniDrop/Resources/Localizable.xcstrings` | one catalog, all languages nested |
| `kmp` | `shared/src/commonMain/composeResources/values[-lang]/strings.xml` | one file per language |
## Workflow
```bash
cd localization
bun run src/cli.ts validate # structural checks (run before committing)
bun run src/cli.ts generate # regenerate .xcstrings + strings.xml from strings.json
bun run src/cli.ts migrate # one-time: rebuild strings.json from existing platform files
```
**Never edit the generated `.xcstrings` / `strings.xml` by hand** — edit `strings.json` and
regenerate. Regenerated output is deterministic (sorted keys), so diffs stay small.
## `strings.json` format
```jsonc
{
"sourceLanguage": "en",
"supportedLanguages": ["en", "fr"],
"strings": {
"send_title": {
"context": "Send tab — screen title.",
"translations": { "en": "Send", "fr": "Envoyer" }
},
"send_selected_files_count": {
"context": "Send flow — number of files chosen before creating a transfer.",
"targets": ["kmp", "apple"],
"args": [{ "name": "count", "type": "int" }],
"plural": {
"en": { "one": "{count} file selected", "other": "{count} files selected" },
"fr": { "one": "{count} fichier sélectionné", "other": "{count} fichiers sélectionnés" }
}
}
}
}
```
### Fields
- **`context`** *(required)* — where the string appears and its purpose. Emitted as the
`.xcstrings` comment and an XML comment; also the note translators see.
- **`targets`** *(optional)*`["kmp", "apple"]`. Omit to mean **all** targets.
- **`args`** *(optional)* — ordered list of `{ name, type }`, `type``string | int | double`.
Referenced in text as `{name}`.
- **`translations`** — flat text per language. **Mutually exclusive with `plural`.**
- **`plural`** — per language, per [CLDR category](https://cldr.unicode.org/index/cldr-spec/plural-rules)
(`zero`, `one`, `two`, `few`, `many`, `other`). `other` is always required.
### Placeholders
Write named tokens `{count}`, `{name}` in text. The generator converts them to the right
positional token per platform, using the declared `type`:
| type | Apple | Android/KMP |
| --- | --- | --- |
| `string` | `%N$@` | `%N$s` |
| `int` | `%N$d` | `%N$d` |
| `double` | `%N$f` | `%N$f` |
A literal `%` in text is emitted as `%%` whenever the string has args.
## Adding a language
Add its code to `supportedLanguages`, fill in `translations` / `plural` for each key, then
`generate`. KMP gets a new `values-<lang>/strings.xml`; Apple gets the language inside the
single catalog. `validate` warns about any key still missing that language.
## Migration notes (from the initial import)
- Apple keys that were literal English strings (`"%@ · %@"`) were imported verbatim — rename
them to semantic keys and update the Swift call sites.
- Arg names default to `arg1`, `arg2`… (a lone int arg becomes `count`). Rename for clarity;
keep the `{token}` in text in sync.
- Folding `transfer_file_count_one` / `_other` into the plural key `transfer_file_count`
requires switching the KMP call site from `Res.string.transfer_file_count_one` to the
Compose plural API (`pluralStringResource(Res.plurals.transfer_file_count, count, count)`),
and the Apple side to automatic plural inflection.

19
localization/bun.lock Normal file
View File

@@ -0,0 +1,19 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "vnidrop-localization",
"devDependencies": {
"bun-types": "^1.3.14",
},
},
},
"packages": {
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
}
}

18
localization/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "vnidrop-localization",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Single-source localization for VniDrop: strings.json -> .xcstrings (Apple) + per-language strings.xml (KMP/Compose).",
"bin": {
"loc": "./src/cli.ts"
},
"scripts": {
"migrate": "bun run src/cli.ts migrate",
"generate": "bun run src/cli.ts generate",
"validate": "bun run src/cli.ts validate"
},
"devDependencies": {
"bun-types": "^1.3.14"
}
}

72
localization/schema.json Normal file
View File

@@ -0,0 +1,72 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "VniDrop localization source",
"type": "object",
"required": ["sourceLanguage", "supportedLanguages", "strings"],
"additionalProperties": false,
"properties": {
"sourceLanguage": { "type": "string", "description": "Default/base language code, e.g. \"en\"." },
"supportedLanguages": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"uniqueItems": true
},
"strings": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/entry" }
}
},
"definitions": {
"entry": {
"type": "object",
"required": ["context"],
"additionalProperties": false,
"oneOf": [
{ "required": ["translations"] },
{ "required": ["plural"] },
{ "not": { "anyOf": [{ "required": ["translations"] }, { "required": ["plural"] }] } }
],
"properties": {
"context": { "type": "string" },
"targets": {
"type": "array",
"items": { "enum": ["kmp", "apple"] },
"uniqueItems": true
},
"args": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "type"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
"type": { "enum": ["string", "int", "double"] }
}
}
},
"translations": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"plural": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["other"],
"additionalProperties": false,
"properties": {
"zero": { "type": "string" },
"one": { "type": "string" },
"two": { "type": "string" },
"few": { "type": "string" },
"many": { "type": "string" },
"other": { "type": "string" }
}
}
}
}
}
}
}

21
localization/src/cli.ts Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bun
import { migrate } from "./commands/migrate";
import { generate } from "./commands/generate";
import { validate } from "./commands/validate";
const [cmd] = process.argv.slice(2);
switch (cmd) {
case "migrate":
await migrate();
break;
case "generate":
await generate();
break;
case "validate":
await validate();
break;
default:
console.error(`Unknown command: ${cmd ?? "(none)"}\nUsage: loc <migrate|generate|validate>`);
process.exit(1);
}

View File

@@ -0,0 +1,97 @@
/**
* Generate platform files from strings.json:
* - Apple: one Localizable.xcstrings (all languages nested).
* - KMP: one values[-lang]/strings.xml per supported language.
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import {
APPLE_XCSTRINGS,
kmpValuesDir,
KMP_RESOURCES,
STRINGS_JSON,
} from "../config";
import { renderAndroid, type ParsedAndroid } from "../lib/android-xml";
import { fromCanonical, type Flavor } from "../lib/placeholders";
import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings";
import { targetsOf, type StringEntry, type StringsFile } from "../types";
function render(entry: StringEntry, text: string, flavor: Flavor): string {
return fromCanonical(text, entry.args ?? [], flavor);
}
// ---- Apple ------------------------------------------------------------------
function buildXcstrings(doc: StringsFile): XcCatalog {
const strings: Record<string, XcEntry> = {};
for (const [key, entry] of Object.entries(doc.strings)) {
if (!targetsOf(entry).includes("apple")) continue;
const xc: XcEntry = { extractionState: "manual" };
if (entry.context) xc.comment = entry.context;
const localizations: XcEntry["localizations"] = {};
for (const lang of doc.supportedLanguages) {
const state = lang === doc.sourceLanguage ? "translated" : "needs_review";
if (entry.plural) {
const forms = entry.plural[lang];
if (!forms) continue;
const plural: Record<string, { stringUnit: { state: any; value: string } }> = {};
for (const [cat, value] of Object.entries(forms)) {
plural[cat] = { stringUnit: { state, value: render(entry, value, "apple") } };
}
localizations[lang] = { variations: { plural } };
} else {
const value = entry.translations?.[lang];
if (value === undefined) continue;
localizations[lang] = { stringUnit: { state, value: render(entry, value, "apple") } };
}
}
if (Object.keys(localizations).length) xc.localizations = localizations;
strings[key] = xc;
}
return { sourceLanguage: doc.sourceLanguage, strings, version: "1.0" };
}
// ---- KMP --------------------------------------------------------------------
function buildAndroid(doc: StringsFile, lang: string): ParsedAndroid {
const out: ParsedAndroid = { strings: [], plurals: [] };
for (const [key, entry] of Object.entries(doc.strings)) {
if (!targetsOf(entry).includes("kmp")) continue;
if (entry.plural) {
const forms = entry.plural[lang];
if (!forms) continue;
const items: Record<string, string> = {};
for (const [cat, value] of Object.entries(forms)) {
items[cat] = render(entry, value, "android");
}
out.plurals.push({ name: key, items });
} else {
const value = entry.translations?.[lang];
if (value === undefined) continue;
out.strings.push({ name: key, value: render(entry, value, "android") });
}
}
return out;
}
// ---- entry ------------------------------------------------------------------
export async function generate() {
const doc = JSON.parse(await Bun.file(STRINGS_JSON).text()) as StringsFile;
await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc)));
console.log(`Wrote ${APPLE_XCSTRINGS}`);
for (const lang of doc.supportedLanguages) {
const dir = join(KMP_RESOURCES, kmpValuesDir(lang, doc.sourceLanguage));
mkdirSync(dir, { recursive: true });
const file = join(dir, "strings.xml");
await Bun.write(file, renderAndroid(buildAndroid(doc, lang)));
console.log(`Wrote ${file}`);
}
}

View File

@@ -0,0 +1,195 @@
/**
* One-time migration: fold the existing .xcstrings (Apple) and Compose
* strings.xml (KMP) files into a single strings.json.
*
* - Placeholders are normalized to canonical `{argN}` tokens.
* - KMP `<base>_one` / `<base>_other` key pairs are folded into one plural entry.
* - `targets` is set from which platform(s) a key was found in.
* Arg names (`arg1`, `count`, …) and `context` are best-effort; curate by hand after.
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { APPLE_XCSTRINGS, KMP_RESOURCES, STRINGS_JSON } from "../config";
import { parseAndroid } from "../lib/android-xml";
import { parseXcstrings, type XcLocalization } from "../lib/xcstrings";
import { toCanonical, type ParsedString } from "../lib/placeholders";
import type { Arg, PluralForms, StringEntry, StringsFile, Target } from "../types";
const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"] as const;
interface Draft {
targets: Set<Target>;
args?: Arg[];
translations?: Record<string, string>;
plural?: Record<string, PluralForms>;
comment?: string;
}
/** Give a friendlier name to a lone integer arg (common for counts). */
function prettify(parsed: ParsedString): ParsedString {
if (parsed.args.length === 1 && parsed.args[0]!.type === "int") {
return {
text: parsed.text.replaceAll("{arg1}", "{count}"),
args: [{ name: "count", type: "int" }],
};
}
return parsed;
}
function ingest(draft: Draft, lang: string, raw: string) {
const p = prettify(toCanonical(raw));
if (p.args.length) draft.args = p.args;
(draft.translations ??= {})[lang] = p.text;
}
// ---- Apple (.xcstrings) -----------------------------------------------------
function unitValue(loc: XcLocalization | undefined): string | undefined {
return loc?.stringUnit?.value;
}
async function readApple(drafts: Map<string, Draft>) {
const cat = parseXcstrings(await Bun.file(APPLE_XCSTRINGS).text());
for (const [key, entry] of Object.entries(cat.strings)) {
const draft = get(drafts, key);
draft.targets.add("apple");
if (entry.comment) draft.comment = entry.comment;
for (const [lang, loc] of Object.entries(entry.localizations ?? {})) {
const plural = loc.variations?.plural;
if (plural) {
for (const [cat, unit] of Object.entries(plural)) {
const p = prettify(toCanonical(unit.stringUnit.value));
if (p.args.length) draft.args = p.args;
((draft.plural ??= {})[lang] ??= { other: "" } as PluralForms)[
cat as keyof PluralForms
] = p.text;
}
} else {
const v = unitValue(loc);
if (v !== undefined) ingest(draft, lang, v);
}
}
}
}
// ---- KMP (compose strings.xml) ---------------------------------------------
function pluralBaseOf(name: string): { base: string; cat: string } | null {
for (const suf of PLURAL_SUFFIXES) {
if (name.endsWith(`_${suf}`)) return { base: name.slice(0, -(suf.length + 1)), cat: suf };
}
return null;
}
async function readKmp(drafts: Map<string, Draft>, sourceLanguage: string) {
const dirs = readdirSync(KMP_RESOURCES, { withFileTypes: true })
.filter((d) => d.isDirectory() && (d.name === "values" || d.name.startsWith("values-")))
.map((d) => d.name);
for (const dir of dirs) {
const lang = dir === "values" ? sourceLanguage : dir.slice("values-".length);
const file = join(KMP_RESOURCES, dir, "strings.xml");
if (!(await Bun.file(file).exists())) continue;
const { strings, plurals } = parseAndroid(await Bun.file(file).text());
for (const s of strings) {
const draft = get(drafts, s.name);
draft.targets.add("kmp");
ingest(draft, lang, s.value);
}
// Real <plurals> blocks (if any) fold directly into the base key.
for (const p of plurals) {
const draft = get(drafts, p.name);
draft.targets.add("kmp");
for (const [cat, raw] of Object.entries(p.items)) {
const parsed = prettify(toCanonical(raw));
if (parsed.args.length) draft.args = parsed.args;
((draft.plural ??= {})[lang] ??= { other: "" } as PluralForms)[
cat as keyof PluralForms
] = parsed.text;
}
}
}
}
/**
* Global fold: collapse flat `<base>_<cat>` sibling keys (e.g. `_one`/`_other`)
* into one plural entry on `<base>`, regardless of which platform they came from.
* Requires `other` plus at least one more category to avoid folding non-plurals.
*/
function foldPlurals(drafts: Map<string, Draft>) {
const groups = new Map<string, string[]>();
for (const key of drafts.keys()) {
const pl = pluralBaseOf(key);
if (pl && drafts.get(key)!.translations) {
(groups.get(pl.base) ?? groups.set(pl.base, []).get(pl.base)!).push(key);
}
}
for (const [base, keys] of groups) {
const cats = new Set(keys.map((k) => pluralBaseOf(k)!.cat));
if (!cats.has("other") || cats.size < 2) continue;
const target = get(drafts, base);
for (const key of keys) {
const cat = pluralBaseOf(key)!.cat as keyof PluralForms;
const src = drafts.get(key)!;
src.targets.forEach((t) => target.targets.add(t));
if (src.args?.length) target.args = src.args;
target.comment ??= src.comment;
for (const [lang, text] of Object.entries(src.translations ?? {})) {
((target.plural ??= {})[lang] ??= { other: "" } as PluralForms)[cat] = text;
}
drafts.delete(key);
}
}
}
// ---- assembly ---------------------------------------------------------------
function get(drafts: Map<string, Draft>, key: string): Draft {
let d = drafts.get(key);
if (!d) drafts.set(key, (d = { targets: new Set() }));
return d;
}
export async function migrate(sourceLanguage = "en") {
const drafts = new Map<string, Draft>();
await readApple(drafts);
await readKmp(drafts, sourceLanguage);
foldPlurals(drafts);
const langs = new Set<string>([sourceLanguage]);
const strings: Record<string, StringEntry> = {};
for (const key of [...drafts.keys()].sort()) {
const d = drafts.get(key)!;
const entry: StringEntry = { context: d.comment ?? "TODO: describe where this appears." };
const targets = [...d.targets].sort() as Target[];
if (targets.length && targets.length < 2) entry.targets = targets;
if (d.args?.length) entry.args = d.args;
if (d.plural) {
entry.plural = d.plural;
Object.keys(d.plural).forEach((l) => langs.add(l));
} else if (d.translations) {
entry.translations = d.translations;
Object.keys(d.translations).forEach((l) => langs.add(l));
}
strings[key] = entry;
}
const out: StringsFile = {
sourceLanguage,
supportedLanguages: [...langs].sort(),
strings,
};
await Bun.write(
STRINGS_JSON,
JSON.stringify({ $schema: "./schema.json", ...out }, null, 2) + "\n",
);
console.log(
`Wrote ${STRINGS_JSON}\n ${Object.keys(strings).length} keys, languages: ${[...langs].sort().join(", ")}`,
);
}

View File

@@ -0,0 +1,55 @@
/** Structural checks on strings.json. Exits non-zero on errors (warnings don't fail). */
import { STRINGS_JSON } from "../config";
import type { StringsFile } from "../types";
const TOKEN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
export async function validate() {
const doc = JSON.parse(await Bun.file(STRINGS_JSON).text()) as StringsFile;
const errors: string[] = [];
const warnings: string[] = [];
for (const [key, entry] of Object.entries(doc.strings)) {
const where = `"${key}"`;
const argNames = new Set((entry.args ?? []).map((a) => a.name));
if (entry.translations && entry.plural) {
errors.push(`${where}: has both "translations" and "plural" (pick one).`);
}
if (!entry.translations && !entry.plural) {
warnings.push(`${where}: no translations yet.`);
}
if (!entry.context?.trim() || entry.context.startsWith("TODO")) {
warnings.push(`${where}: missing/placeholder context.`);
}
const texts: string[] = [];
if (entry.translations) texts.push(...Object.values(entry.translations));
if (entry.plural) {
for (const [lang, forms] of Object.entries(entry.plural)) {
if (!forms.other?.trim()) errors.push(`${where} [${lang}]: plural missing required "other".`);
texts.push(...Object.values(forms));
}
}
for (const text of texts) {
for (const m of text.matchAll(TOKEN)) {
if (!argNames.has(m[1]!)) {
errors.push(`${where}: uses {${m[1]}} but no matching arg is declared.`);
}
}
}
// Missing-language coverage (warn only).
for (const lang of doc.supportedLanguages) {
const present = entry.plural ? !!entry.plural[lang] : entry.translations?.[lang] !== undefined;
const anyTranslations = entry.translations || entry.plural;
if (anyTranslations && !present) warnings.push(`${where}: missing "${lang}".`);
}
}
for (const w of warnings) console.warn(`warn ${w}`);
for (const e of errors) console.error(`error ${e}`);
console.log(`\n${Object.keys(doc.strings).length} keys — ${errors.length} error(s), ${warnings.length} warning(s).`);
if (errors.length) process.exit(1);
}

View File

@@ -0,0 +1,24 @@
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** Repo root = parent of the localization/ directory. */
export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
export const LOC_DIR = join(REPO_ROOT, "localization");
export const STRINGS_JSON = join(LOC_DIR, "strings.json");
/** Apple String Catalog — one file, all languages nested. */
export const APPLE_XCSTRINGS = join(
REPO_ROOT,
"apple/VniDrop/Resources/Localizable.xcstrings",
);
/** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */
export const KMP_RESOURCES = join(
REPO_ROOT,
"shared/src/commonMain/composeResources",
);
/** Directory holding a language's strings.xml under the compose resources root. */
export function kmpValuesDir(lang: string, sourceLanguage: string): string {
return lang === sourceLanguage ? "values" : `values-${lang}`;
}

View File

@@ -0,0 +1,96 @@
/**
* Read/write Compose Multiplatform (Android-style) strings.xml.
* The existing file is flat and machine-friendly, so we use a targeted parser
* rather than a full DOM. Handles <string> and <plurals> with XML entities and
* Android backslash escapes.
*/
export interface AndroidString {
name: string;
value: string;
}
export interface AndroidPlural {
name: string;
items: Record<string, string>; // quantity -> value
}
export interface ParsedAndroid {
strings: AndroidString[];
plurals: AndroidPlural[];
}
function decodeEntities(s: string): string {
return s
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(parseInt(d, 10)))
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'")
.replaceAll("&amp;", "&");
}
/** Reverse Android's leading/trailing-quote + backslash escapes into plain text. */
function unescapeAndroid(raw: string): string {
let s = raw.trim();
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
s = s.slice(1, -1);
}
s = s
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\@/g, "@")
.replace(/\\\\/g, "\\");
return decodeEntities(s);
}
export function parseAndroid(xml: string): ParsedAndroid {
const strings: AndroidString[] = [];
const plurals: AndroidPlural[] = [];
const stringRe = /<string\s+name="([^"]+)"\s*>([\s\S]*?)<\/string>/g;
for (const m of xml.matchAll(stringRe)) {
strings.push({ name: m[1]!, value: unescapeAndroid(m[2]!) });
}
const pluralRe = /<plurals\s+name="([^"]+)"\s*>([\s\S]*?)<\/plurals>/g;
const itemRe = /<item\s+quantity="([^"]+)"\s*>([\s\S]*?)<\/item>/g;
for (const m of xml.matchAll(pluralRe)) {
const items: Record<string, string> = {};
for (const it of m[2]!.matchAll(itemRe)) {
items[it[1]!] = unescapeAndroid(it[2]!);
}
plurals.push({ name: m[1]!, items });
}
return { strings, plurals };
}
function escapeXmlText(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
/** Android needs apostrophes and quotes escaped, and quotes on leading/trailing whitespace. */
function escapeAndroidValue(s: string): string {
let out = escapeXmlText(s).replace(/'/g, "\\'").replace(/\n/g, "\\n");
if (out !== out.trim()) out = `"${out}"`;
return out;
}
export function renderAndroid(parsed: ParsedAndroid): string {
const lines: string[] = ['<?xml version="1.0" encoding="utf-8"?>', "<resources>"];
for (const s of parsed.strings) {
lines.push(` <string name="${s.name}">${escapeAndroidValue(s.value)}</string>`);
}
for (const p of parsed.plurals) {
lines.push(` <plurals name="${p.name}">`);
for (const [q, v] of Object.entries(p.items)) {
lines.push(` <item quantity="${q}">${escapeAndroidValue(v)}</item>`);
}
lines.push(" </plurals>");
}
lines.push("</resources>", "");
return lines.join("\n");
}

View File

@@ -0,0 +1,98 @@
/**
* Canonical placeholder handling.
*
* Canonical form (in strings.json) uses named tokens: `{count}`, `{name}`.
* Platform output uses positional printf tokens, resolved from the entry's
* ordered `args`:
* - Apple (.xcstrings): string -> %N$@ int -> %N$d double -> %N$f
* - Android (.xml): string -> %N$s int -> %N$d double -> %N$f
* A literal percent is written `%%` on both platforms whenever the string
* carries args (printf-active); canonical text stores it as a bare `%`.
*/
import type { Arg, ArgType } from "../types";
export type Flavor = "apple" | "android";
function stringToken(flavor: Flavor): string {
return flavor === "apple" ? "@" : "s";
}
function tokenForType(type: ArgType, flavor: Flavor): string {
switch (type) {
case "string":
return stringToken(flavor);
case "int":
return "d";
case "double":
return "f";
}
}
/** Render canonical `{name}` text into a platform printf string. */
export function fromCanonical(
text: string,
args: Arg[],
flavor: Flavor,
): string {
const hasArgs = args.length > 0;
const index = new Map(args.map((a, i) => [a.name, { i: i + 1, type: a.type }]));
// Escape literal % first (only meaningful when printf-active).
let out = hasArgs ? text.replaceAll("%", "%%") : text;
out = out.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_whole, name: string) => {
const found = index.get(name);
if (!found) {
throw new Error(
`placeholder {${name}} has no matching arg (${args.map((a) => a.name).join(", ") || "none"})`,
);
}
return `%${found.i}$${tokenForType(found.type, flavor)}`;
});
return out;
}
export interface ParsedString {
/** Canonical text with `{argN}` tokens. */
text: string;
/** Args discovered, ordered by first positional index. */
args: Arg[];
}
// Matches an escaped literal `%%` or a printf specifier (positional or bare).
const CONV = /%%|%(?:(\d+)\$)?([@sdif])/g;
function typeForConv(conv: string): ArgType {
if (conv === "@" || conv === "s") return "string";
if (conv === "d" || conv === "i") return "int";
return "double"; // f
}
/**
* Parse a platform printf string back into canonical `{argN}` form, inferring
* arg names (`arg1`, `arg2`, ...) and types from the conversion specifiers.
* Handles positional (`%1$@`), bare (`%@`), and escaped literals (`%%`).
*/
export function toCanonical(raw: string): ParsedString {
const byIndex = new Map<number, ArgType>();
let auto = 0;
const text = raw.replace(CONV, (whole, pos: string | undefined, conv: string) => {
if (whole === "%%") return "%"; // literal percent, not a specifier
const idx = pos ? Number(pos) : ++auto;
const type = typeForConv(conv);
const existing = byIndex.get(idx);
if (existing && existing !== type) {
throw new Error(`arg ${idx} used with conflicting types in "${raw}"`);
}
byIndex.set(idx, type);
return `{arg${idx}}`;
});
const args: Arg[] = [...byIndex.keys()]
.sort((a, b) => a - b)
.map((idx) => ({ name: `arg${idx}`, type: byIndex.get(idx)! }));
return { text, args };
}

View File

@@ -0,0 +1,45 @@
/** Read/write Apple String Catalog (.xcstrings) — a single JSON file, all languages nested. */
export type XcState = "new" | "translated" | "needs_review" | "stale";
export interface XcStringUnit {
state: XcState;
value: string;
}
export interface XcVariations {
plural?: Record<string, { stringUnit: XcStringUnit }>;
}
export interface XcLocalization {
stringUnit?: XcStringUnit;
variations?: XcVariations;
}
export interface XcEntry {
comment?: string;
extractionState?: string;
localizations?: Record<string, XcLocalization>;
}
export interface XcCatalog {
sourceLanguage: string;
strings: Record<string, XcEntry>;
version: string;
}
export function parseXcstrings(json: string): XcCatalog {
return JSON.parse(json) as XcCatalog;
}
/** Stable, Xcode-compatible serialization: 2-space indent, keys sorted, trailing newline. */
export function renderXcstrings(cat: XcCatalog): string {
const sortDeep = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortDeep);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
out[k] = sortDeep((v as Record<string, unknown>)[k]);
}
return out;
}
return v;
};
return JSON.stringify(sortDeep(cat), null, 2) + "\n";
}

43
localization/src/types.ts Normal file
View File

@@ -0,0 +1,43 @@
/** Canonical localization data model. See ../README.md for the full spec. */
/** Output targets a string can be emitted to. */
export type Target = "kmp" | "apple";
export const ALL_TARGETS: Target[] = ["kmp", "apple"];
/** Argument types, used to pick the right platform placeholder token. */
export type ArgType = "string" | "int" | "double";
export interface Arg {
/** Name referenced inside translations as `{name}`. */
name: string;
type: ArgType;
}
/** CLDR plural categories. `other` is always required when `plural` is used. */
export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
export type PluralForms = Partial<Record<PluralCategory, string>> & {
other: string;
};
export interface StringEntry {
/** Where the string appears / its purpose. Emitted as xcstrings comment + xml comment. */
context: string;
/** Defaults to all targets when omitted. */
targets?: Target[];
/** Ordered positional args referenced as `{name}` in translations. */
args?: Arg[];
/** Flat (non-plural) translations, keyed by language. Mutually exclusive with `plural`. */
translations?: Record<string, string>;
/** Plural translations, keyed by language then CLDR category. Mutually exclusive with `translations`. */
plural?: Record<string, PluralForms>;
}
export interface StringsFile {
sourceLanguage: string;
supportedLanguages: string[];
strings: Record<string, StringEntry>;
}
export function targetsOf(entry: StringEntry): Target[] {
return entry.targets ?? ALL_TARGETS;
}

1741
localization/strings.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}

View File

@@ -1,218 +1,220 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nav_send">Send</string>
<string name="nav_receive">Receive</string>
<string name="nav_settings">Settings</string>
<string name="send_title">Send</string>
<string name="send_subtitle">Transfers youre sharing from this device.</string>
<string name="send_empty_title">Nothing shared yet</string>
<string name="send_empty_body">Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file.</string>
<string name="button_create_new_transfer">New transfer</string>
<string name="send_transfers_title">Your transfers</string>
<string name="send_new_transfer_title">New transfer</string>
<string name="send_choose_file_title">Choose what to share</string>
<string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
<string name="send_selected_files_count">%1$d files selected</string>
<string name="send_folder_label">Folder</string>
<string name="button_remove_file">Remove file</string>
<string name="button_choose_files">Choose files</string>
<string name="button_change_files">Change files</string>
<string name="send_review_title">Review transfer</string>
<string name="send_access_title">Who can receive it?</string>
<string name="send_access_approval">Ask before each download</string>
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
<string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for items you are comfortable sharing.</string>
<string name="send_access_anyone_warning">Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.</string>
<string name="send_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string>
<string name="transfer_activity_title">Activity</string>
<string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_receivers_title">Receivers</string>
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
<string name="transfer_share_title">Share</string>
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
<string name="transfer_delete_title">Delete transfer?</string>
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
<string name="transfer_deleting">Deleting…</string>
<string name="transfer_deleted">Transfer deleted.</string>
<string name="transfer_no_activity">There is no activity to show yet.</string>
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
<string name="transfer_receiver_requested">Waiting for your approval</string>
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
<string name="transfer_receiver_refused">Request refused</string>
<string name="transfer_receiver_expired">Request expired</string>
<string name="transfer_receiver_completed">Received successfully</string>
<string name="transfer_receiver_unknown">Status unavailable</string>
<string name="transfer_nearby_device">Nearby device</string>
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
<string name="button_write_nfc">Write to NFC tag</string>
<string name="button_download_invitation">Save .vnd file</string>
<string name="button_native_share">Share invitation</string>
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
<string name="transfer_invitation_saved">Invitation saved.</string>
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
<string name="transfer_event_preparing">Preparing your transfer</string>
<string name="transfer_event_ready">Ready to share</string>
<string name="transfer_event_requested">A receiver requested access</string>
<string name="transfer_event_approved">Receiver access approved</string>
<string name="transfer_event_refused">Receiver access refused</string>
<string name="transfer_event_completed">A receiver completed the transfer</string>
<string name="transfer_event_stopped">Sharing stopped</string>
<string name="transfer_event_failed">The transfer encountered a problem</string>
<string name="transfer_event_updated">Transfer updated</string>
<string name="button_share_file">Start sharing</string>
<string name="button_sharing_file">Preparing transfer…</string>
<string name="send_new_transfer_description">Create a new transfer</string>
<string name="button_clear">Clear</string>
<string name="transfer_details_title">Transfer details</string>
<string name="field_transfer_name">Transfer name</string>
<string name="field_sender_name">Sender name</string>
<string name="button_refuse">Refuse</string>
<string name="button_approve">Approve</string>
<string name="receive_title">Receive</string>
<string name="receive_new_subtitle">Transfers youve received on this device.</string>
<string name="receive_empty_title">Nothing received yet</string>
<string name="receive_empty_body">Open a VniDrop invitation, scan a QR code, or hold near an NFC tag.</string>
<string name="button_receive_files">Start receiving</string>
<string name="receive_history_title">History</string>
<string name="receive_clear_history">Clear history</string>
<string name="receive_delete_history_item">Delete from receive history</string>
<string name="receive_delete_history_title">Remove from history?</string>
<string name="receive_delete_history_description">“%1$s” will be removed from VniDrops history. The downloaded file will remain on this device.</string>
<string name="receive_clear_history_title">Clear receive history?</string>
<string name="receive_clear_history_description">All completed, failed, and cancelled receives will be removed from VniDrops history. Downloaded files will remain on this device.</string>
<string name="receive_history_cleared">Receive history cleared.</string>
<string name="receive_choose_method_title">How would you like to connect?</string>
<string name="receive_choose_method_body">Choose the invitation method available to you.</string>
<string name="receive_method_file">Open a .vnd invitation</string>
<string name="receive_method_file_description">Choose an invitation saved or shared to this device.</string>
<string name="receive_method_scan">Scan QR code</string>
<string name="receive_method_scan_description">Use the camera to scan the senders VniDrop code.</string>
<string name="receive_method_nfc">Read NFC tag</string>
<string name="receive_method_nfc_description">Hold this device near the senders invitation tag.</string>
<string name="receive_nfc_waiting">Hold near the NFC tag…</string>
<string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="receive_completed">Transfer received.</string>
<string name="button_show_in_files">Show in Files</string>
<string name="receive_open_files_failed">Couldnt open VniDrop in Files.</string>
<string name="field_receiver_name">Receiver name</string>
<string name="button_receive">Receive</string>
<string name="button_retry">Retry</string>
<string name="button_cancel_receive">Cancel</string>
<string name="progress_receiving">Receiving</string>
<string name="progress_preparing">Preparing</string>
<string name="progress_ready">Ready</string>
<string name="progress_share_ready">Ready to share</string>
<string name="progress_connecting">Connecting</string>
<string name="progress_connected">Connected</string>
<string name="progress_requesting_access">Requesting access</string>
<string name="progress_getting_ready">Getting ready</string>
<string name="progress_downloading">Downloading</string>
<string name="progress_saving">Saving</string>
<string name="progress_sending">Sending</string>
<string name="progress_completed">Completed</string>
<string name="progress_cancelled">Cancelled</string>
<string name="progress_failed">Failed</string>
<string name="progress_interrupted">Transfer interrupted</string>
<string name="progress_working">Working…</string>
<string name="status_preparing">Preparing</string>
<string name="status_available">Available</string>
<string name="status_receiving">Receiving</string>
<string name="status_completed">Completed</string>
<string name="status_cancelled">Cancelled</string>
<string name="status_stopped">Stopped</string>
<string name="status_failed">Failed</string>
<string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_completed_count">%1$d completed</string>
<string name="transfer_event_downloading">Downloading</string>
<string name="transfer_event_saving">Saving</string>
<string name="transfer_event_connecting">Connecting to sender</string>
<string name="transfer_file_count_one">%1$d file</string>
<string name="transfer_file_count_other">%1$d files</string>
<string name="settings_title">Settings</string>
<string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string>
<string name="appearance_title">Appearance</string>
<string name="preferences_title">Preferences</string>
<string name="field_username">Display name</string>
<string name="preferences_receive_folder_title">Save received transfers to</string>
<string name="button_choose_folder">Choose folder</string>
<string name="button_reset_default">Use default</string>
<string name="button_back">Back</string>
<string name="button_close">Close</string>
<string name="button_cancel">Cancel</string>
<string name="button_delete_transfer">Delete transfer</string>
<string name="folder_status_writable">Ready</string>
<string name="folder_status_permission_required">Permission needed</string>
<string name="folder_status_unavailable">Unavailable</string>
<string name="folder_status_validating">Checking folder…</string>
<string name="appearance_system_mode">System</string>
<string name="about_bug_report">Report a bug</string>
<string name="about_privacy">Privacy policy</string>
<string name="about_title">About</string>
<string name="appearance_auto_description">Match this devices light or dark appearance.</string>
<string name="appearance_dark_mode">Dark mode</string>
<string name="appearance_light_mode">Light mode</string>
<string name="appearance_auto_description">Match this devices light or dark appearance.</string>
<string name="about_title">About</string>
<string name="about_privacy">Privacy policy</string>
<string name="about_bug_report">Report a bug</string>
<string name="diagnostics_title">Share diagnostics</string>
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.</string>
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
<string name="bug_report_description">Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).</string>
<string name="bug_report_what_label">What happened?</string>
<string name="bug_report_expected_label">What did you expect?</string>
<string name="bug_report_steps_label">Steps to reproduce (optional)</string>
<string name="appearance_system_mode">System</string>
<string name="appearance_title">Appearance</string>
<string name="approval_connection_request">Receive request</string>
<string name="approval_endpoint_id">Device ID: %1$s</string>
<string name="approval_nearby_device">A nearby device</string>
<string name="approval_pending_count">%1$d requests waiting</string>
<string name="approval_request_body">%1$s wants to receive “%2$s”.</string>
<string name="battery_level_title">Battery level</string>
<string name="bug_report_contact_label">Contact email (optional)</string>
<string name="bug_report_description">Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted).</string>
<string name="bug_report_device_section">Device information</string>
<string name="bug_report_expected_label">What did you expect?</string>
<string name="bug_report_include_logs">Include recent logs</string>
<string name="bug_report_include_logs_description">Helps us diagnose the issue. Sensitive values are redacted before sending.</string>
<string name="bug_report_logs_size">Log attachment size</string>
<string name="bug_report_device_section">Device information</string>
<string name="bug_report_submit">Submit report</string>
<string name="bug_report_submitting">Submitting…</string>
<string name="bug_report_submitted">Thanks — your bug report was recorded.</string>
<string name="bug_report_submit_failed">Could not submit the bug report. Try again later.</string>
<string name="bug_report_missing_what">Please describe what happened.</string>
<string name="bug_report_missing_expected">Please describe what you expected.</string>
<string name="version_title">App version</string>
<string name="device_name_title">Device name</string>
<string name="device_model_title">Device model</string>
<string name="os_version_title">Operating system</string>
<string name="network_title">Network</string>
<string name="battery_level_title">Battery level</string>
<string name="value_unavailable">Not available</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_local_title">Allow notifications</string>
<string name="notifications_description">Get notified about new receive requests while VniDrop is in the background.</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
<string name="notifications_unsupported">Notifications are not available on this device.</string>
<string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="bug_report_missing_what">Please describe what happened.</string>
<string name="bug_report_steps_label">Steps to reproduce (optional)</string>
<string name="bug_report_submit">Submit report</string>
<string name="bug_report_submit_failed">Could not submit the bug report. Try again later.</string>
<string name="bug_report_submitted">Thanks — your bug report was recorded.</string>
<string name="bug_report_submitting">Submitting…</string>
<string name="bug_report_what_label">What happened?</string>
<string name="button_approve">Approve</string>
<string name="button_back">Back</string>
<string name="button_cancel">Cancel</string>
<string name="button_cancel_receive">Cancel</string>
<string name="button_change_files">Change files</string>
<string name="button_choose_files">Choose files</string>
<string name="button_choose_folder">Choose folder</string>
<string name="button_clear">Clear</string>
<string name="button_close">Close</string>
<string name="button_create_new_transfer">New transfer</string>
<string name="button_delete_transfer">Delete transfer</string>
<string name="button_download_invitation">Save .vnd file</string>
<string name="button_native_share">Share invitation</string>
<string name="button_open_settings">Open Settings</string>
<string name="snackbar_dismiss">Dismiss</string>
<string name="approval_connection_request">Receive request</string>
<string name="approval_request_body">%1$s wants to receive “%2$s”.</string>
<string name="approval_nearby_device">A nearby device</string>
<string name="approval_endpoint_id">Device ID: %1$s</string>
<string name="approval_pending_count">%1$d requests waiting</string>
<string name="metadata_size">Size</string>
<string name="metadata_files">Files</string>
<string name="metadata_status">Status</string>
<string name="button_receive">Receive</string>
<string name="button_receive_files">Start receiving</string>
<string name="button_refuse">Refuse</string>
<string name="button_remove_file">Remove file</string>
<string name="button_reset_default">Use default</string>
<string name="button_retry">Retry</string>
<string name="button_share_file">Start sharing</string>
<string name="button_sharing_file">Preparing transfer…</string>
<string name="button_show_in_files">Show in Files</string>
<string name="button_write_nfc">Write to NFC tag</string>
<string name="device_model_title">Device model</string>
<string name="device_name_title">Device name</string>
<string name="diagnostics_description">Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included.</string>
<string name="diagnostics_disabled_message">Diagnostics sharing is off.</string>
<string name="diagnostics_enabled_message">Diagnostics sharing is on.</string>
<string name="diagnostics_title">Share diagnostics</string>
<string name="error_camera">Camera access is required to scan a QR code.</string>
<string name="error_device_info">Could not load device information.</string>
<string name="error_filesystem">VniDrop could not access the selected files or folder. Check permissions and try again.</string>
<string name="error_generic">Something went wrong. Try again.</string>
<string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_initialization">VniDrop could not finish starting up. Close the app and try again.</string>
<string name="error_invalid_ticket">This invitation could not be read. Ask the sender for a new one.</string>
<string name="error_invitation_empty">That invitation is empty. Try opening it again.</string>
<string name="error_permission">The sender has not approved this transfer, or it was refused.</string>
<string name="error_filesystem">VniDrop could not access the selected files or folder. Check permissions and try again.</string>
<string name="error_transfer">The transfer could not be completed. Check your connection and try again.</string>
<string name="error_repository">VniDrop could not save transfer data on this device.</string>
<string name="error_initialization">VniDrop could not finish starting up. Close the app and try again.</string>
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
<string name="error_missing_native_library">The native VniDrop library is missing from this build.</string>
<string name="error_selection_failed">Could not open the selected item. Try choosing it again.</string>
<string name="error_device_info">Could not load device information.</string>
<string name="error_nfc">This NFC tag could not be used. Try another tag.</string>
<string name="error_camera">Camera access is required to scan a QR code.</string>
<string name="error_permission">The sender has not approved this transfer, or it was refused.</string>
<string name="error_repository">VniDrop could not save transfer data on this device.</string>
<string name="error_selection_failed">Could not open the selected item. Try choosing it again.</string>
<string name="error_share_empty">Select at least one item to share.</string>
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
<string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_transfer">The transfer could not be completed. Check your connection and try again.</string>
<string name="field_receiver_name">Receiver name</string>
<string name="field_sender_name">Sender name</string>
<string name="field_transfer_name">Transfer name</string>
<string name="field_username">Display name</string>
<string name="folder_status_permission_required">Permission needed</string>
<string name="folder_status_unavailable">Unavailable</string>
<string name="folder_status_validating">Checking folder…</string>
<string name="folder_status_writable">Ready</string>
<string name="metadata_files">Files</string>
<string name="metadata_size">Size</string>
<string name="metadata_status">Status</string>
<string name="nav_receive">Receive</string>
<string name="nav_send">Send</string>
<string name="nav_settings">Settings</string>
<string name="network_title">Network</string>
<string name="notifications_description">Get notified about new receive requests while VniDrop is in the background.</string>
<string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_local_title">Allow notifications</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_unsupported">Notifications are not available on this device.</string>
<string name="os_version_title">Operating system</string>
<string name="preferences_receive_folder_title">Save received transfers to</string>
<string name="preferences_title">Preferences</string>
<string name="progress_cancelled">Cancelled</string>
<string name="progress_completed">Completed</string>
<string name="progress_connected">Connected</string>
<string name="progress_connecting">Connecting</string>
<string name="progress_downloading">Downloading</string>
<string name="progress_failed">Failed</string>
<string name="progress_getting_ready">Getting ready</string>
<string name="progress_interrupted">Transfer interrupted</string>
<string name="progress_preparing">Preparing</string>
<string name="progress_ready">Ready</string>
<string name="progress_receiving">Receiving</string>
<string name="progress_requesting_access">Requesting access</string>
<string name="progress_saving">Saving</string>
<string name="progress_sending">Sending</string>
<string name="progress_share_ready">Ready to share</string>
<string name="progress_working">Working…</string>
<string name="receive_choose_method_body">Choose the invitation method available to you.</string>
<string name="receive_choose_method_title">How would you like to connect?</string>
<string name="receive_clear_history">Clear history</string>
<string name="receive_clear_history_description">All completed, failed, and cancelled receives will be removed from VniDrops history. Downloaded files will remain on this device.</string>
<string name="receive_clear_history_title">Clear receive history?</string>
<string name="receive_completed">Transfer received.</string>
<string name="receive_delete_history_description">“%1$s” will be removed from VniDrops history. The downloaded file will remain on this device.</string>
<string name="receive_delete_history_item">Delete from receive history</string>
<string name="receive_delete_history_title">Remove from history?</string>
<string name="receive_empty_body">Open a VniDrop invitation, scan a QR code, or hold near an NFC tag.</string>
<string name="receive_empty_title">Nothing received yet</string>
<string name="receive_history_cleared">Receive history cleared.</string>
<string name="receive_history_title">History</string>
<string name="receive_method_file">Open a .vnd invitation</string>
<string name="receive_method_file_description">Choose an invitation saved or shared to this device.</string>
<string name="receive_method_nfc">Read NFC tag</string>
<string name="receive_method_nfc_description">Hold this device near the senders invitation tag.</string>
<string name="receive_method_scan">Scan QR code</string>
<string name="receive_method_scan_description">Use the camera to scan the senders VniDrop code.</string>
<string name="receive_new_subtitle">Transfers youve received on this device.</string>
<string name="receive_nfc_waiting">Hold near the NFC tag…</string>
<string name="receive_open_files_failed">Couldnt open VniDrop in Files.</string>
<string name="receive_review_title">Review transfer</string>
<string name="receive_title">Receive</string>
<string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for items you are comfortable sharing.</string>
<string name="send_access_anyone_warning">Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.</string>
<string name="send_access_approval">Ask before each download</string>
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
<string name="send_access_title">Who can receive it?</string>
<string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
<string name="send_choose_file_title">Choose what to share</string>
<string name="send_empty_body">Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file.</string>
<string name="send_empty_title">Nothing shared yet</string>
<string name="send_file_size_unknown">Size unavailable</string>
<string name="send_folder_label">Folder</string>
<string name="send_new_transfer_description">Create a new transfer</string>
<string name="send_new_transfer_title">New transfer</string>
<string name="send_review_title">Review transfer</string>
<string name="send_selected_files_count">%1$d files selected</string>
<string name="send_subtitle">Transfers youre sharing from this device.</string>
<string name="send_title">Send</string>
<string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string>
<string name="send_transfers_title">Your transfers</string>
<string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string>
<string name="settings_title">Settings</string>
<string name="snackbar_dismiss">Dismiss</string>
<string name="status_available">Available</string>
<string name="status_cancelled">Cancelled</string>
<string name="status_completed">Completed</string>
<string name="status_failed">Failed</string>
<string name="status_preparing">Preparing</string>
<string name="status_receiving">Receiving</string>
<string name="status_stopped">Stopped</string>
<string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_activity_title">Activity</string>
<string name="transfer_delete_description">“%1$s” will stop being shared and its transfer history will be removed from this device.</string>
<string name="transfer_delete_title">Delete transfer?</string>
<string name="transfer_deleted">Transfer deleted.</string>
<string name="transfer_deleting">Deleting…</string>
<string name="transfer_details_title">Transfer details</string>
<string name="transfer_event_approved">Receiver access approved</string>
<string name="transfer_event_completed">A receiver completed the transfer</string>
<string name="transfer_event_connecting">Connecting to sender</string>
<string name="transfer_event_downloading">Downloading</string>
<string name="transfer_event_failed">The transfer encountered a problem</string>
<string name="transfer_event_preparing">Preparing your transfer</string>
<string name="transfer_event_ready">Ready to share</string>
<string name="transfer_event_refused">Receiver access refused</string>
<string name="transfer_event_requested">A receiver requested access</string>
<string name="transfer_event_saving">Saving</string>
<string name="transfer_event_stopped">Sharing stopped</string>
<string name="transfer_event_updated">Transfer updated</string>
<string name="transfer_invitation_saved">Invitation saved.</string>
<string name="transfer_nearby_device">Nearby device</string>
<string name="transfer_nfc_unavailable">NFC tag writing is not available on this device.</string>
<string name="transfer_nfc_waiting">Hold your device near a writable NFC tag.</string>
<string name="transfer_nfc_written">Invitation written to the NFC tag.</string>
<string name="transfer_no_activity">There is no activity to show yet.</string>
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
<string name="transfer_receiver_completed">Received successfully</string>
<string name="transfer_receiver_expired">Request expired</string>
<string name="transfer_receiver_refused">Request refused</string>
<string name="transfer_receiver_requested">Waiting for your approval</string>
<string name="transfer_receiver_unknown">Status unavailable</string>
<string name="transfer_receivers_completed_count">%1$d completed</string>
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
<string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_title">Receivers</string>
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
<string name="transfer_share_description">QR code, invitation file, and nearby options</string>
<string name="transfer_share_title">Share</string>
<string name="value_unavailable">Not available</string>
<string name="version_title">App version</string>
<plurals name="transfer_file_count">
<item quantity="other">%1$d files</item>
<item quantity="one">%1$d file</item>
</plurals>
</resources>

View File

@@ -5,6 +5,7 @@ import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import kotlin.math.roundToInt
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
@@ -30,8 +31,7 @@ import vnidrop.shared.generated.resources.status_failed
import vnidrop.shared.generated.resources.status_preparing
import vnidrop.shared.generated.resources.status_receiving
import vnidrop.shared.generated.resources.status_stopped
import vnidrop.shared.generated.resources.transfer_file_count_one
import vnidrop.shared.generated.resources.transfer_file_count_other
import vnidrop.shared.generated.resources.transfer_file_count
enum class WindowClass {
Phone,
@@ -206,9 +206,8 @@ fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
.take(6)
.mapNotNull { progressForTransfer(events, it) }
/** File-count string resource for transfer subtitles (resolve with [stringResource]). */
fun transferFileCountResource(fileCount: ULong): StringResource =
if (fileCount == 1UL) Res.string.transfer_file_count_one else Res.string.transfer_file_count_other
/** File-count plural resource for transfer subtitles (resolve with [pluralStringResource]). */
fun transferFileCountResource(): PluralStringResource = Res.plurals.transfer_file_count
fun formatBytes(size: ULong): String {
val value = size.toDouble()

View File

@@ -20,8 +20,7 @@ import vnidrop.shared.generated.resources.progress_interrupted
import vnidrop.shared.generated.resources.progress_saving
import vnidrop.shared.generated.resources.progress_sending
import vnidrop.shared.generated.resources.progress_working
import vnidrop.shared.generated.resources.transfer_file_count_one
import vnidrop.shared.generated.resources.transfer_file_count_other
import vnidrop.shared.generated.resources.transfer_file_count
class AppUiModelsTest {
@Test
@@ -235,10 +234,8 @@ class AppUiModelsTest {
}
@Test
fun transferFileCountPicksSingularAndPluralResources() {
assertEquals(Res.string.transfer_file_count_one, transferFileCountResource(1UL))
assertEquals(Res.string.transfer_file_count_other, transferFileCountResource(2UL))
assertEquals(Res.string.transfer_file_count_other, transferFileCountResource(0UL))
fun transferFileCountUsesPluralResource() {
assertEquals(Res.plurals.transfer_file_count, transferFileCountResource())
}
@Test