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
22 changes: 10 additions & 12 deletions cmd/opensave-app/frontend/src/components/StatusBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { native } from '../lib/api.js';
import AboutModal from './AboutModal.svelte';
import { paletteOpen } from '../lib/shortcuts.js';
import { t } from '../lib/i18n.js';

const modKey = /Mac/i.test(globalThis.navigator?.platform ?? '') ? '⌘' : 'Ctrl';

Expand Down Expand Up @@ -44,9 +45,6 @@
$: if ($wsConnected) connectedOnce = true;
$: lost = connectedOnce && !$wsConnected;
$: onlinePeers = Object.values($peers).filter((p) => p.status === 'online').length;
$: statusText = running.length
? `Syncing ${running.length} game${running.length > 1 ? 's' : ''}…`
: 'No syncs in progress';
</script>

{#if $showAbout}
Expand All @@ -62,38 +60,38 @@
<footer>
<div class="left">
{#if lost}
<span class="offline" title="The app has lost touch with its background service and is reconnecting">Reconnecting…</span>
<span class="offline" title={$t('statusBar.reconnectingTitle')}>{$t('statusBar.reconnecting')}</span>
{:else if $syncPause.paused}
<span class="paused" title="Syncing is paused {pauseLength($syncPause, now)}. Snapshots are still taken.">
Syncing paused · {pauseShort($syncPause, now)}
</span>
<button class="bar-btn" on:click={resumeSync} title="Resume syncing and catch up"><Play size={11} />Resume</button>
<button class="bar-btn" on:click={resumeSync} title={$t('statusBar.resumeTitle')}><Play size={11} />{$t('statusBar.resume')}</button>
{:else}
<span>{statusText}</span>
{#if running.length}<span>{$t('statusBar.syncing', { n: running.length })}</span>{:else}<span>{$t('statusBar.noSyncs')}</span>{/if}
{#if running.length && running[0][1].percentage != null}
<span class="pct">{running[0][1].percentage}%</span>
{/if}
<button class="bar-btn icon" on:click={pauseMenu} title="Pause syncing" aria-label="Pause syncing"><Pause size={11} /></button>
<button class="bar-btn icon" on:click={pauseMenu} title={$t('statusBar.pause')} aria-label={$t('statusBar.pause')}><Pause size={11} /></button>
{/if}
</div>
<div class="right">
{#if $wanRoom?.connected}
<span class="wan">relay: {$wanRoom.roomCode}</span>
{/if}
<span>{onlinePeers} {onlinePeers === 1 ? 'device' : 'devices'} online</span>
<span>{$t('statusBar.devicesOnline', { n: onlinePeers })}</span>
<button
class="bar-btn"
class:on={transfersOpen}
on:click={() => (transfersOpen = !transfersOpen)}
title="What is moving between your devices"
title={$t('statusBar.transfersHint')}
aria-expanded={transfersOpen}
>
<ArrowDownUp size={11} />Transfers{#if running.length}<span class="count">{running.length}</span>{/if}
<ArrowDownUp size={11} />{$t('transfers.title')}{#if running.length}<span class="count">{running.length}</span>{/if}
</button>
<button class="kbd-hint" on:click={() => paletteOpen.set(true)} title="Jump to a game, a page or an action">
<button class="kbd-hint" on:click={() => paletteOpen.set(true)} title={$t('statusBar.paletteHint')}>
<kbd>{modKey} K</kbd>
</button>
<button class="ver" on:click={() => showAbout.set(true)} title="About OpenSave">OpenSave v{version}</button>
<button class="ver" on:click={() => showAbout.set(true)} title={$t('statusBar.aboutTitle')}>OpenSave v{version}</button>
</div>
</footer>

Expand Down
7 changes: 4 additions & 3 deletions cmd/opensave-app/frontend/src/components/TitleBar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import { native } from '../lib/api.js';
import { t } from '../lib/i18n.js';
import NotificationHub from './NotificationHub.svelte';
import logoUrl from '../assets/logo.png';
</script>
Expand All @@ -12,13 +13,13 @@
<div class="right">
<NotificationHub />
<div class="controls" style="--wails-draggable: no-drag">
<button on:click={() => native.minimise()} title="Minimise" aria-label="Minimise">
<button on:click={() => native.minimise()} title={$t('titleBar.minimise')} aria-label={$t('titleBar.minimise')}>
<svg width="10" height="10" viewBox="0 0 10 10"><line x1="1" y1="5.5" x2="9" y2="5.5" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button on:click={() => native.toggleMaximise()} title="Maximise" aria-label="Maximise">
<button on:click={() => native.toggleMaximise()} title={$t('titleBar.maximise')} aria-label={$t('titleBar.maximise')}>
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="close" on:click={() => native.close()} title="Close" aria-label="Close">
<button class="close" on:click={() => native.close()} title={$t('common.close')} aria-label={$t('common.close')}>
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1.5 1.5 L8.5 8.5 M8.5 1.5 L1.5 8.5" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
</div>
Expand Down
4 changes: 3 additions & 1 deletion cmd/opensave-app/frontend/src/components/Toasts.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script>
import { toasts, dismissToast } from '../lib/stores.js';
// `t` is a toast in the loop below, so the store comes in under its own name.
import { t as i18n } from '../lib/i18n.js';
import X from 'lucide-svelte/icons/x';
import { flip } from 'svelte/animate';
import { gated, slideAway } from '../lib/motion.js';
Expand All @@ -19,7 +21,7 @@
<span class="message">{t.message}</span>
{#if t.action}
<button class="btn small" on:click={() => act(t)}>{t.action.label}</button>
<button class="btn small ghost icon" aria-label="Dismiss" title="Dismiss" on:click={() => dismissToast(t.id)}><X size={14} /></button>
<button class="btn small ghost icon" aria-label={$i18n('common.dismiss')} title={$i18n('common.dismiss')} on:click={() => dismissToast(t.id)}><X size={14} /></button>
{/if}
</div>
{/each}
Expand Down
15 changes: 8 additions & 7 deletions cmd/opensave-app/frontend/src/components/TransfersPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { games, syncActivity, navigate } from '../lib/stores.js';
import { timeAgo } from '../lib/timeago.js';
import { amountLabel, peerLabel, speedLabel, withNames } from '../lib/transfers.js';
import { t } from '../lib/i18n.js';

const dispatch = createEventDispatcher();
let data = null;
Expand Down Expand Up @@ -46,21 +47,21 @@
}
</script>

<div class="panel" role="dialog" aria-label="Transfers">
<div class="panel" role="dialog" aria-label={$t('transfers.title')}>
<div class="head">
<h3>Transfers</h3>
<button class="btn small ghost icon" aria-label="Close" title="Close" on:click={() => dispatch('close')}><X size={14} /></button>
<h3>{$t('transfers.title')}</h3>
<button class="btn small ghost icon" aria-label={$t('common.close')} title={$t('common.close')} on:click={() => dispatch('close')}><X size={14} /></button>
</div>

{#if error}
<p class="empty">Couldn't read transfers: {error}</p>
<p class="empty">{$t('transfers.readError', { error })}</p>
{:else if !data}
<div class="skeleton" style="height: 44px; margin: 10px 14px"></div>
{:else if shown.active.length === 0 && shown.recent.length === 0}
<p class="empty">Nothing has moved between your devices since OpenSave started.</p>
<p class="empty">{$t('transfers.empty')}</p>
{:else}
{#if shown.active.length}
<h4>Now</h4>
<h4>{$t('transfers.now')}</h4>
{#each shown.active as t}
<button class="row" on:click={() => open(t)}>
<span class="dir {t.direction}"><svelte:component this={t.direction === 'upload' ? ArrowUp : ArrowDown} size={14} /></span>
Expand All @@ -74,7 +75,7 @@
{/each}
{/if}
{#if shown.recent.length}
<h4>Recently</h4>
<h4>{$t('transfers.recently')}</h4>
{#each shown.recent.slice(0, 20) as t}
<button class="row" on:click={() => open(t)} title={t.error || ''}>
<span class="dir {t.direction}"><svelte:component this={t.direction === 'upload' ? ArrowUp : ArrowDown} size={14} /></span>
Expand Down
3 changes: 2 additions & 1 deletion cmd/opensave-app/frontend/src/components/ui/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { onMount } from 'svelte';
import X from 'lucide-svelte/icons/x';
import { backdropClose } from '../../lib/backdrop.js';
import { t } from '../../lib/i18n.js';

export let title = '';
/** A lucide icon component drawn before the title. */
Expand Down Expand Up @@ -54,7 +55,7 @@
</div>
<div class="head-actions">
<slot name="actions" />
<button class="btn ghost icon" disabled={!closable} on:click={close} title="Close" aria-label="Close"><X size={18} /></button>
<button class="btn ghost icon" disabled={!closable} on:click={close} title={$t('common.close')} aria-label={$t('common.close')}><X size={18} /></button>
</div>
</div>
<slot />
Expand Down
4 changes: 3 additions & 1 deletion cmd/opensave-app/frontend/src/components/ui/Skeleton.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script>
import { t } from '../../lib/i18n.js';

// The shape of a page while its data is on the way, in place of the word
// "Loading": the eye settles on where things will be, and nothing jumps
// when they arrive.
Expand All @@ -9,7 +11,7 @@
export let count = 3;
</script>

<div class="skel {kind}" aria-busy="true" aria-label="Loading">
<div class="skel {kind}" aria-busy="true" aria-label={$t('common.loading')}>
{#if kind === 'tiles'}
<div class="skeleton bar" style="width: 100%; height: 64px; margin-bottom: 22px; border-radius: var(--radius-lg)"></div>
<div class="grid">
Expand Down
39 changes: 32 additions & 7 deletions cmd/opensave-app/frontend/src/lib/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,30 @@
import { writable, derived } from 'svelte/store';

import en from '../locales/en.json';
import zhCN from '../locales/zh-CN.json';
import zhHans from '../locales/zh-Hans.json';
import zhHant from '../locales/zh-Hant.json';

export const SYSTEM_LOCALE = 'system';

/** The languages offered besides the system default. */
export const LOCALES = { en: 'English', 'zh-CN': '简体中文' };
/** The languages offered besides the system default.
*
* Chinese is split by script, not by region: which characters a reader wants
* is not the same question as where they are, so `zh-CN` / `zh-TW` / `zh-HK`
* and the rest are not offered as separate languages. Each region maps onto
* one of the two scripts here instead. */
export const LOCALES = { en: 'English', 'zh-Hans': '简体中文', 'zh-Hant': '繁體中文' };

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

/** A locale id if valid, otherwise null. */
/** A locale id if valid, otherwise null. A Chinese tag saved before the
* script split (`zh-CN`, `zh-TW`, `zh-HK`, bare `zh`, …) maps through
* `scriptFor`, so someone who picked Chinese keeps it. */
export function sanitizeLocale(raw) {
return typeof raw === 'string' && (raw === SYSTEM_LOCALE || raw in LOCALES) ? raw : null;
if (typeof raw !== 'string') return null;
if (raw === SYSTEM_LOCALE || raw in LOCALES) return raw;
return scriptFor(raw);
}

/** The saved choice, or 'system' when the user has never picked one. */
Expand All @@ -49,10 +59,25 @@ export function saveLocale(l, storage = globalThis.localStorage) {
}
}

/** Regions whose Chinese is written with traditional characters. Every other
* Chinese region — and a bare 'zh' — is read as simplified. */
const TRADITIONAL_REGIONS = ['tw', 'hk', 'mo'];

/** The Chinese script an OS/browser language tag is written in, or null when
* the tag is not Chinese. A tag that already names its script wins over the
* region guess. */
export function scriptFor(language) {
const tag = (language ?? '').toLowerCase();
if (!tag.startsWith('zh')) return null;
if (tag.startsWith('zh-hant')) return 'zh-Hant';
if (tag.startsWith('zh-hans')) return 'zh-Hans';
return TRADITIONAL_REGIONS.includes(tag.split('-')[1]) ? 'zh-Hant' : 'zh-Hans';
}

/** '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;
return scriptFor(language) ?? FALLBACK;
}

/** The chosen language. Setting it persists immediately ('system' unpersists). */
Expand Down
62 changes: 48 additions & 14 deletions cmd/opensave-app/frontend/src/lib/i18n.test.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,39 @@
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';
import zhHans from '../locales/zh-Hans.json';
import zhHant from '../locales/zh-Hant.json';
import { LOCALES, loadLocale, pluralRulesFor, resolveLocale, sanitizeLocale, scriptFor, 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);

/** Every catalog, as [locale id, catalog]. A new language is one line here
* plus one in LOCALES; the checks below then cover it for free. */
const CATALOGS = [['en', en], ['zh-Hans', zhHans], ['zh-Hant', zhHant]];

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

it('has no keys in zh-CN that en.json does not have', () => {
it('has no key outside en.json in any catalog', () => {
const enKeys = new Set(keysOf(en));
for (const key of keysOf(zhCN)) expect(enKeys.has(key), `extra key: ${key}`).toBe(true);
for (const [name, catalog] of CATALOGS) {
for (const key of keysOf(catalog)) expect(enKeys.has(key), `${name}: 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('has a value for every key in en.json, in every catalog', () => {
for (const [name, catalog] of CATALOGS) {
const keys = new Set(keysOf(catalog));
for (const key of keysOf(en)) expect(keys.has(key), `${name}: 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 [name, catalog] of CATALOGS) {
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');
Expand Down Expand Up @@ -74,22 +83,47 @@ describe('segments', () => {
});

describe('locale choice', () => {
it('rejects unknown locale ids', () => {
it('rejects unknown locale ids, and region tags that are not languages', () => {
expect(sanitizeLocale('fr')).toBe(null);
expect(sanitizeLocale('zh-CN')).toBe('zh-CN');
expect(sanitizeLocale('zh-Hans')).toBe('zh-Hans');
expect(sanitizeLocale('zh-Hant')).toBe('zh-Hant');
expect(sanitizeLocale('system')).toBe('system');
});

it('maps a Chinese choice saved before the script split onto its script', () => {
expect(sanitizeLocale('zh-CN')).toBe('zh-Hans');
expect(sanitizeLocale('zh-SG')).toBe('zh-Hans');
expect(sanitizeLocale('zh-TW')).toBe('zh-Hant');
expect(sanitizeLocale('zh-HK')).toBe('zh-Hant');
expect(sanitizeLocale('zh')).toBe('zh-Hans');
expect(loadLocale({ getItem: () => 'zh-CN' })).toBe('zh-Hans');
expect(loadLocale({ getItem: () => 'zh-TW' })).toBe('zh-Hant');
});

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

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

it('reads the Chinese script out of an OS language tag', () => {
expect(scriptFor('zh-CN')).toBe('zh-Hans');
expect(scriptFor('zh-SG')).toBe('zh-Hans');
expect(scriptFor('zh-Hans-CN')).toBe('zh-Hans');
expect(scriptFor('zh')).toBe('zh-Hans');
expect(scriptFor('zh-TW')).toBe('zh-Hant');
expect(scriptFor('zh-HK')).toBe('zh-Hant');
expect(scriptFor('zh-MO')).toBe('zh-Hant');
expect(scriptFor('zh-Hant-HK')).toBe('zh-Hant');
expect(scriptFor('en-US')).toBe(null);
expect(scriptFor('')).toBe(null);
});

it('survives storage that refuses to read', () => {
Expand Down
2 changes: 1 addition & 1 deletion cmd/opensave-app/frontend/src/lib/restoreprogress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('restoreProgressText', () => {
expect(say('en', { phase: 'later', done: 0, total: 0 })).toContain('Restoring');
});
it('says the line in the chosen language', () => {
const line = say('zh-CN', { phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 });
const line = say('zh-Hans', { phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 });
expect(line).toContain('正在写入文件');
expect(line).toContain('25%');
expect(line).toContain('约 3 分钟');
Expand Down
Loading
Loading