Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/opensave-app/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { applyMessage, wsConnected, view, appUpdate, toast, showAbout, cloudOffers, newGames, navigate, settings, games, availableUpdate } from './lib/stores.js';
import { startController, controllerOn, padUsed, pageStep } from './lib/controller.js';
import { appearance } from './lib/appearance.js';
import { locale, resolveLocale } from './lib/i18n.js';
import { paletteOpen } from './lib/shortcuts.js';
import { newlyEmptied } from './lib/emptied.js';
import { demandAttention } from './lib/notify.js';
Expand Down Expand Up @@ -85,6 +86,9 @@
// with no pointer, it is the only way to see where you are.
$: controllerActive = controllerOn($appearance.controller, { deviceType: $settings?.deviceType, used: $padUsed });
$: document.documentElement.dataset.controller = controllerActive ? 'on' : 'off';
// The document language follows the chosen locale ('system' resolved); a
// wrong lang makes Windows render some Han characters with Japanese shapes.
$: document.documentElement.lang = resolveLocale($locale);
onMount(() =>
startController({
isOn: () => controllerActive,
Expand Down
12 changes: 10 additions & 2 deletions cmd/opensave-app/frontend/src/lib/format.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Small formatters shared by the views.

import { get } from 'svelte/store';
import { locale, pluralRulesFor } from './i18n.js';

/** Bytes as B, KB, MB or GB, one decimal past bytes: "185 B", "3.2 KB",
* "1.4 MB", "9.5 GB". GB so free space on a drive reads as "9.5 GB" and not
* "9724.7 MB"; bytes so a small save file is not "0.0 KB". */
Expand All @@ -15,8 +18,13 @@ export const fmtSize = (n) =>
/** A timestamp in the viewer's locale, or a dash for none. */
export const fmtTime = (t) => (t ? new Date(t).toLocaleString() : '—');

/** "1 game", "3 games". */
export const plural = (n, word, many = word + 's') => `${n} ${n === 1 ? word : many}`;
/** "1 game", "3 games". The singular/plural choice goes through the chosen
* locale's Intl.PluralRules (English one/other; other languages get their
* own categories once their views are translated). */
export const plural = (n, word, many = word + 's') => {
const category = pluralRulesFor(get(locale)).select(Number(n ?? 0));
return `${n} ${category === 'one' ? word : many}`;
};

/** How long something was played: "45 min", "3 h", "14 h 20 min". */
export function playLength(ms) {
Expand Down
5 changes: 5 additions & 0 deletions cmd/opensave-app/frontend/src/lib/format.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { fmtSize, fmtTime, playLength, plural } from './format.js';
import { locale } from './i18n.js';

// The plural tests assert English wording; pin the language so the suite does
// not depend on the machine it runs on.
locale.set('en');

describe('fmtSize', () => {
it('shows bytes below a kilobyte, KB below a megabyte and MB from one', () => {
Expand Down
113 changes: 113 additions & 0 deletions cmd/opensave-app/frontend/src/lib/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// A tiny, dependency-free i18n layer. `t` is a Svelte store whose value is a
// translate function: in a component, `$t('key')` re-renders when the user
// switches language, with no framework wiring.
//
// Catalogs live in src/locales/ as flat, dot-keyed JSON. `en.json` is the
// source of truth: any key missing from another locale falls back to English,
// so a partial translation never shows a blank. A plural value is an object
// keyed by Intl.PluralRules categories (`{ "one": …, "other": … }`); Chinese
// and other single-form languages just use a string.
//
// The language choice is 'system' until the user picks one, and only an
// explicit pick is persisted — so an unpicked app keeps following the OS
// language as it changes.
import { writable, derived } from 'svelte/store';

import en from '../locales/en.json';
import zhCN from '../locales/zh-CN.json';

export const SYSTEM_LOCALE = 'system';

/** The languages offered besides the system default. */
export const LOCALES = { en: 'English', 'zh-CN': '简体中文' };

const CATALOGS = { en, 'zh-CN': zhCN };
const FALLBACK = 'en';
const KEY = 'opensave.locale';

/** A locale id if valid, otherwise null. */
export function sanitizeLocale(raw) {
return typeof raw === 'string' && (raw === SYSTEM_LOCALE || raw in LOCALES) ? raw : null;
}

/** The saved choice, or 'system' when the user has never picked one. */
export function loadLocale(storage = globalThis.localStorage) {
try {
return sanitizeLocale(storage?.getItem(KEY)) ?? SYSTEM_LOCALE;
} catch {
return SYSTEM_LOCALE;
}
}

/** Persist an explicit pick; 'system' clears it, following the OS again. */
export function saveLocale(l, storage = globalThis.localStorage) {
try {
if (l === SYSTEM_LOCALE) storage?.removeItem(KEY);
else storage?.setItem(KEY, l);
} catch {
// Storage refused: the choice lasts until restart.
}
}

/** 'system' settled against the OS language, or the explicit pick itself. */
export function resolveLocale(l, language = globalThis.navigator?.language) {
if (l && l !== SYSTEM_LOCALE) return l;
return (language ?? '').toLowerCase().startsWith('zh') ? 'zh-CN' : FALLBACK;
}

/** The chosen language. Setting it persists immediately ('system' unpersists). */
export const locale = writable(loadLocale());
locale.subscribe((l) => saveLocale(l));

/** Plural categories for a locale, as Intl.PluralRules sees them. */
export function pluralRulesFor(l) {
try {
return new Intl.PluralRules(l);
} catch {
return new Intl.PluralRules(FALLBACK);
}
}

/** Look a key up with English fallback, resolve plurals, fill {params}. */
export function translate(catalog, key, params, rules) {
let entry = catalog[key];
if (entry === undefined) entry = CATALOGS[FALLBACK][key];
if (entry === undefined) return key;
if (entry !== null && typeof entry === 'object') {
const category = rules.select(Number(params?.n ?? 0));
entry = entry[category] ?? entry.other ?? key;
// A plural template always mentions {n}; fill it even when the caller
// passed no params at all.
params = { n: 0, ...params };
}
if (params) {
for (const [name, value] of Object.entries(params)) {
entry = entry.replaceAll(`{${name}}`, String(value));
}
}
return entry;
}

/** The translate function, rebound whenever the language changes. */
export const t = derived(locale, (l) => {
const catalog = CATALOGS[resolveLocale(l)] ?? {};
const rules = pluralRulesFor(resolveLocale(l));
return (key, params) => translate(catalog, key, params, rules);
});

/** Split a translated template on its {placeholders}, marking which filled
* parts should render in bold. Returns [{ text, bold }, …] in order. */
export function segments(template, params = {}, boldNames = []) {
const out = [];
const re = /{(\w+)}/g;
let last = 0;
let m;
while ((m = re.exec(template))) {
if (m.index > last) out.push({ text: template.slice(last, m.index), bold: false });
const name = m[1];
out.push({ text: String(params[name] ?? m[0]), bold: boldNames.includes(name) });
last = re.lastIndex;
}
if (last < template.length) out.push({ text: template.slice(last), bold: false });
return out;
}
99 changes: 99 additions & 0 deletions cmd/opensave-app/frontend/src/lib/i18n.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';

import en from '../locales/en.json';
import zhCN from '../locales/zh-CN.json';
import { LOCALES, loadLocale, pluralRulesFor, resolveLocale, sanitizeLocale, segments, translate } from './i18n.js';

// The catalogs are flat, dot-keyed objects; a plural value is one value that
// must expose an "other" form. Comparing top-level keys is enough.
const keysOf = (catalog) => Object.keys(catalog);

describe('locale catalogs', () => {
it('declares every catalog id in LOCALES', () => {
for (const id of ['en', 'zh-CN']) expect(LOCALES[id], id).toBeTruthy();
});

it('has no keys in zh-CN that en.json does not have', () => {
const enKeys = new Set(keysOf(en));
for (const key of keysOf(zhCN)) expect(enKeys.has(key), `extra key: ${key}`).toBe(true);
});

it('has a zh-CN value for every key in en.json', () => {
const zhKeys = new Set(keysOf(zhCN));
for (const key of keysOf(en)) expect(zhKeys.has(key), `missing key: ${key}`).toBe(true);
});

it('keeps every value non-empty, and plurals with an "other" form', () => {
for (const [catalog, name] of [[en, 'en'], [zhCN, 'zh-CN']]) {
for (const [key, v] of Object.entries(catalog)) {
if (v !== null && typeof v === 'object') {
expect(typeof v.other, `${name}:${key} plural needs "other"`).toBe('string');
expect(v.other.trim().length, `${name}:${key}`).toBeGreaterThan(0);
} else {
expect(typeof v, `${name}:${key}`).toBe('string');
expect(v.trim().length, `${name}:${key}`).toBeGreaterThan(0);
}
}
}
});
});

describe('translate', () => {
const rules = pluralRulesFor('en');

it('returns the key itself when nothing has it', () => {
expect(translate({}, 'no.such.key', undefined, rules)).toBe('no.such.key');
});

it('falls back to English when the locale lacks the key', () => {
expect(translate({ other: 'x' }, 'home.title', undefined, rules)).toBe('Home');
});

it('fills {params}', () => {
expect(translate({ k: '{game}: got {count} files' }, 'k', { game: 'Hades', count: 7 }, rules)).toBe('Hades: got 7 files');
});

it('selects plural categories and always keeps an "other"', () => {
const catalog = { k: { one: '{n} snapshot', other: '{n} snapshots' } };
expect(translate(catalog, 'k', { n: 1 }, rules)).toBe('1 snapshot');
expect(translate(catalog, 'k', { n: 3 }, rules)).toBe('3 snapshots');
expect(translate(catalog, 'k', undefined, rules)).toBe('0 snapshots');
});
});

describe('segments', () => {
it('splits a template into plain and bold parts, in order', () => {
expect(segments('open {a} then {b}.', { a: '设备', b: '云备份' }, ['b'])).toEqual([
{ text: 'open ', bold: false },
{ text: '设备', bold: false },
{ text: ' then ', bold: false },
{ text: '云备份', bold: true },
{ text: '.', bold: false }
]);
});
});

describe('locale choice', () => {
it('rejects unknown locale ids', () => {
expect(sanitizeLocale('fr')).toBe(null);
expect(sanitizeLocale('zh-CN')).toBe('zh-CN');
expect(sanitizeLocale('system')).toBe('system');
});

it("reports 'system' when the user has never picked one", () => {
expect(loadLocale({ getItem: () => null })).toBe('system');
expect(loadLocale({ getItem: () => 'zh-CN' })).toBe('zh-CN');
});

it('resolves system against the OS language, keeps explicit picks', () => {
expect(resolveLocale('system', 'zh-CN')).toBe('zh-CN');
expect(resolveLocale('system', 'en-US')).toBe('en');
expect(resolveLocale('system', '')).toBe('en');
expect(resolveLocale('zh-CN', 'en-US')).toBe('zh-CN');
});

it('survives storage that refuses to read', () => {
const storage = { getItem: () => { throw new Error('blocked'); } };
expect(loadLocale(storage)).toBe('system');
});
});
5 changes: 5 additions & 0 deletions cmd/opensave-app/frontend/src/lib/timeline.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe as suite, expect, it } from 'vitest';
import { byDay, describe, filterItems, playedWhere, runs } from './timeline.js';
import { locale } from './i18n.js';

// The expected titles are English; pin the language so the suite does not
// depend on the machine it runs on.
locale.set('en');

const now = new Date(2026, 8, 25, 18, 0);
const at = (h, m = 0, day = 25) => new Date(2026, 8, day, h, m).getTime();
Expand Down
Loading
Loading