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
2 changes: 1 addition & 1 deletion .github/workflows/screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
run: |
rm -rf website/public/screenshots
mkdir -p website/public/screenshots website/src/data
cp -r screenshots/out/en screenshots/out/de website/public/screenshots/
cp -r screenshots/out/en screenshots/out/de screenshots/out/zh website/public/screenshots/
cp screenshots/out/manifest.json website/src/data/screenshots.json

- name: Commit and push, if anything actually changed
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ popover, not for the person who wrote the commit.

## [Unreleased]

## [1.10.0] - 2026-10-01

### Added
- **Stashpad is now available in Simplified Chinese.** Pick 简体中文 under Settings ›
General, or leave the language on automatic: a system set to Chinese for mainland China or
Singapore switches over by itself. A system set to Traditional Chinese (Taiwan, Hong Kong,
Macau) stays in English, because the translation is in Simplified characters only, and
you can still choose it by hand

## [1.9.1] - 2026-10-01

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stashpad",
"version": "1.9.1",
"version": "1.10.0",
"description": "The staging area for your AI context.",
"author": {
"name": "Nico Wiedemann",
Expand Down
27 changes: 24 additions & 3 deletions screenshots/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* `screenshots/out/`. The website's release job picks those up; nothing here writes
* outside this folder.
*
* npm run screenshots every scene, both locales
* npm run screenshots every scene, every locale
* npm run screenshots -- --lang en one locale
* npm run screenshots -- --scene queue,settings
* npm run screenshots -- --headed watch it happen
Expand All @@ -31,6 +31,7 @@
*/

import { spawn } from 'node:child_process';
import { readFileSync, readdirSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -46,7 +47,27 @@ const VIEWPORT = { width: 800, height: 600 };
const SCALE = 2;
const PORT = Number(process.env.SCREENSHOT_PORT ?? 5199);

const LOCALES = ['en', 'de'];
/**
* Every locale the app ships, read from its locale files so a new language is captured
* without anyone remembering this list.
*/
const LOCALE_FILES = Object.fromEntries(
readdirSync(join(APP_ROOT, 'src/lib/i18n/locales'))
.filter((f) => f.endsWith('.json'))
.map((f) => [f.slice(0, -5), JSON.parse(readFileSync(join(APP_ROOT, 'src/lib/i18n/locales', f), 'utf8'))]),
);
const LOCALES = Object.keys(LOCALE_FILES).sort((a, b) => (a === 'en' ? -1 : b === 'en' ? 1 : a.localeCompare(b)));

/**
* The UI text for an i18n key in every locale, as patterns for `clickByText`. Matching on
* the translations themselves, rather than a hand-written English and German pattern, is
* what lets a scene drive the app in a language this file has never heard of.
*/
function labels(key) {
return LOCALES.map((locale) => key.split('.').reduce((node, part) => node?.[part], LOCALE_FILES[locale]))
.filter((text) => typeof text === 'string')
.map((text) => new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'));
}

/**
* What to photograph.
Expand Down Expand Up @@ -92,7 +113,7 @@ const SCENES = [
id: 'contexts',
async drive(page) {
await openSettings(page);
await clickByText(page, [/manage contexts/i, /kontexte verwalten/i]);
await clickByText(page, labels('settings.contextManagement.manageContexts'));
await page.waitForTimeout(400);
// Sort by last used, so the context the demo is sitting in leads instead of
// whichever one happens to sort first alphabetically.
Expand Down
2 changes: 1 addition & 1 deletion screenshots/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* bottom rather than a static one at the top.
*
* Query parameters, all optional:
* ?lang=en|de locale to boot in (default: en)
* ?lang=en|de|zh locale to boot in (default: en)
* ?theme=dark|light
* ?signedOut=1 cloud sync signed out, for the "connect" state
*/
Expand Down
14 changes: 14 additions & 0 deletions screenshots/mock-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@ export function installMockBackend(overrides: Partial<DemoState> = {}): void {
return `/home/dev/.stashpad/cache/${payload?.contextId}/${payload?.stashId}/refs/${payload?.targetId}.md`;
case 'get_system_prompt_path_str':
return '/home/dev/.stashpad/system-prompt.md';
case 'e2ee_status':
// Encryption never turned on: the panel offers to turn it on, which is the
// state almost every account is in. Without an answer the panel renders its
// error line into the settings capture.
return {
epoch: 0,
state: 'off',
unlocked: false,
enrolled: false,
hasRecovery: false,
recoveryAcknowledged: false,
fingerprint: '',
devices: [],
};
default:
if (NO_OP.has(cmd)) return null;
console.warn(`[demo] unhandled command: ${cmd}`);
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stashpad"
version = "1.9.1"
version = "1.10.0"
description = "The staging area for your AI context."
authors = ["Nico Wiedemann <nico.wiedemann@gmail.com>"]
repository = "https://github.com/EarMaster/stashpad"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ pub fn run() {
let _ = (|| -> Result<(), Box<dyn std::error::Error>> {
let check_updates_label = match locale.as_deref() {
Some("de") => "Nach Updates suchen…",
Some("zh") => "检查更新…",
_ => "Check for Updates…",
};
let check_updates_item = MenuItemBuilder::new(check_updates_label)
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "stashpad",
"version": "1.9.1",
"version": "1.10.0",
"identifier": "org.stashpad",
"build": {
"frontendDist": "../dist",
Expand Down
6 changes: 5 additions & 1 deletion src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@

/* Tailwind Theme Extensions */
@theme {
--font-sans: "Inter", system-ui, Avenir, Helvetica, Arial, sans-serif;
/* Inter has no CJK glyphs. Naming the system Chinese fonts before `system-ui` makes Chinese
render in PingFang (macOS), Microsoft YaHei (Windows) or Noto (Linux) rather than in whatever
the platform's generic fallback picks, while Latin text stays in Inter. */
--font-sans: "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC",
"Noto Sans SC", system-ui, Avenir, Helvetica, Arial, sans-serif;
--color-background: var(--background);
}

Expand Down
6 changes: 3 additions & 3 deletions src/lib/components/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@
<span class="text-purple-500 font-medium">Enterprise</span>
— {$_("settings.cloudSync.subscription.active")}
{:else}
<span class="text-muted-foreground">Free</span>
<span class="text-muted-foreground">{$_("settings.cloudSync.subscription.free")}</span>
— {$_("settings.cloudSync.subscription.freeNote")}
{/if}
</div>
Expand All @@ -899,8 +899,8 @@
{/snippet}
{settings.cloudConfig.subscriptionTier === "free" ||
!settings.cloudConfig.subscriptionTier
? "Upgrade"
: "Manage"}
? $_("settings.cloudSync.subscription.upgrade")
: $_("settings.cloudSync.subscription.manage")}
</SettingsButton>
</div>
{/if}
Expand Down
83 changes: 83 additions & 0 deletions src/lib/i18n/__tests__/locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-only

// Copyright (C) 2026 Nico Wiedemann
//
// This file is part of Stashpad.
// Stashpad is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License, version 3,
// as published by the Free Software Foundation.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU Affero General Public License for more details.

import { describe, expect, it } from 'vitest';
import { isTraditionalChinese, matchLocale } from '../index';

describe('matchLocale', () => {
it.each([
['en-US', 'en'],
['de-AT', 'de'],
['zh', 'zh'],
['zh-CN', 'zh'],
['zh-SG', 'zh'],
['zh-Hans-CN', 'zh'],
['zh_CN', 'zh'],
])('maps %s to %s', (tag, expected) => {
expect(matchLocale(tag)).toBe(expected);
});

// Only Simplified Chinese ships. A Traditional reader gets English rather than the
// other script.
it.each([['zh-TW'], ['zh-HK'], ['zh-MO'], ['zh-Hant'], ['zh-Hant-TW']])(
'leaves %s unmatched',
(tag) => {
expect(isTraditionalChinese(tag)).toBe(true);
expect(matchLocale(tag)).toBeNull();
},
);

it('leaves an unsupported language unmatched', () => {
expect(matchLocale('fr-FR')).toBeNull();
});
});

describe('locale files', () => {
// svelte-i18n falls back to English for a missing key without a word, so a string
// added to en.json alone ships as English in the German and Chinese UI.
const files = import.meta.glob('../locales/*.json', { eager: true, import: 'default' }) as Record<
string,
Record<string, unknown>
>;
const leaves = (node: unknown, prefix = ''): Map<string, string> => {
const out = new Map<string, string>();
if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node)) {
if (key === '_meta') continue;
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') out.set(path, value);
else for (const [p, v] of leaves(value, path)) out.set(p, v);
}
}
return out;
};
// Names only: `{count}`, and `count` in `{count, plural, ...}` - not the branches of an
// ICU plural, whose text is translated.
const placeholders = (text: string) =>
[...text.matchAll(/\{\s*([A-Za-z_][\w.]*)\s*[,}]/g)].map((m) => m[1]).sort();

const en = leaves(files['../locales/en.json']);
const others = Object.entries(files).filter(([path]) => !path.endsWith('/en.json'));

it('include German and Chinese', () => {
expect(others.map(([path]) => path).sort()).toEqual(['../locales/de.json', '../locales/zh.json']);
});

it.each(others)('%s has exactly the keys and placeholders of en.json', (_path, content) => {
const theirs = leaves(content);
expect([...theirs.keys()].sort()).toEqual([...en.keys()].sort());
for (const [key, text] of en) {
expect(placeholders(theirs.get(key) ?? ''), key).toEqual(placeholders(text));
}
});
});
47 changes: 30 additions & 17 deletions src/lib/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,30 +71,43 @@ function registerLocales(): void {
}

/**
* Determines the best locale to use based on browser/OS settings.
* Falls back to DEFAULT_LOCALE if the browser's locale is not supported.
* Whether a language tag asks for Traditional Chinese: the `Hant` script, or a region that
* writes it (Taiwan, Hong Kong, Macau).
*
* The app ships Simplified Chinese only, as `zh`. Matching on the primary subtag alone would
* hand it to a `zh-TW` system, and many Traditional readers would rather read English than be
* served the other script - so those fall through to the default instead.
*/
export function getAutoDetectedLocale(): SupportedLocale {
const browserLocale = getLocaleFromNavigator();

if (!browserLocale) {
return DEFAULT_LOCALE;
}
export function isTraditionalChinese(tag: string): boolean {
const parts = tag.toLowerCase().split(/[-_]/);
if (parts[0] !== "zh") return false;
return parts.slice(1).some((p) => p === "hant" || p === "tw" || p === "hk" || p === "mo");
}

/**
* The supported locale for a language tag, or `null` when there is none.
*/
export function matchLocale(tag: string): SupportedLocale | null {
// First try exact match (e.g. en-US)
if (SUPPORTED_LOCALES.includes(browserLocale)) {
return browserLocale;
if (SUPPORTED_LOCALES.includes(tag)) {
return tag;
}
if (isTraditionalChinese(tag)) {
return null;
}

// Extract the primary language code (e.g., "en-US" -> "en")
const primaryLanguage = browserLocale.split("-")[0].toLowerCase();

// Check if the primary language is supported
if (SUPPORTED_LOCALES.includes(primaryLanguage)) {
return primaryLanguage;
}
const primaryLanguage = tag.split(/[-_]/)[0].toLowerCase();
return SUPPORTED_LOCALES.includes(primaryLanguage) ? primaryLanguage : null;
}

return DEFAULT_LOCALE;
/**
* Determines the best locale to use based on browser/OS settings.
* Falls back to DEFAULT_LOCALE if the browser's locale is not supported.
*/
export function getAutoDetectedLocale(): SupportedLocale {
const browserLocale = getLocaleFromNavigator();
return (browserLocale && matchLocale(browserLocale)) || DEFAULT_LOCALE;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@
"subscription": {
"label": "Abonnement",
"active": "Aktiv",
"freeNote": "Cloud-Synchronisierung erfordert ein Abonnement"
"freeNote": "Cloud-Synchronisierung erfordert ein Abonnement",
"free": "Kostenlos",
"upgrade": "Upgrade",
"manage": "Verwalten"
},
"usage": {
"attachments": "{count} Anhänge",
Expand Down
5 changes: 4 additions & 1 deletion src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@
"subscription": {
"label": "Subscription",
"active": "Active",
"freeNote": "Cloud sync requires a subscription"
"freeNote": "Cloud sync requires a subscription",
"free": "Free",
"upgrade": "Upgrade",
"manage": "Manage"
},
"usage": {
"attachments": "{count} attachments",
Expand Down
Loading
Loading