From a0f56822cd4bf419f4a4fcf6086bbdd95f0588ec Mon Sep 17 00:00:00 2001 From: Jokin Date: Tue, 14 Jul 2026 15:52:54 +0200 Subject: [PATCH] Internationalization: 7 languages with live switching Custom signal-based runtime (no i18n dependency): shared src/shared/i18n dictionaries, ui I18nService.t() reactive by construction so language switches live, and a backend i18n.ts for native menus + thrown errors. Every user-facing string keyed across templates, toasts, dialogs, file kinds, columns, macOS menu/context menus, and errors. Language picker in Options (auto-detects OS locale, falls back to English); switching relabels the webview and native chrome instantly. English/Spanish/French/ German fully drafted; Chinese/Japanese/Basque drafted, flagged for native review. Docs updated. Co-Authored-By: Claude Fable 5 --- docs/architecture.md | 18 +- docs/gotchas.md | 19 ++ src/bun/context-menu.ts | 64 ++-- src/bun/fs/registry.ts | 5 +- src/bun/fs/sftp.ts | 5 +- src/bun/i18n.ts | 38 +++ src/bun/index.ts | 7 + src/bun/menu.ts | 29 +- src/bun/transfers/engine.ts | 9 +- src/shared/i18n/README.md | 35 ++ src/shared/i18n/de.ts | 299 +++++++++++++++++ src/shared/i18n/en.ts | 303 ++++++++++++++++++ src/shared/i18n/es.ts | 299 +++++++++++++++++ src/shared/i18n/eu.ts | 301 +++++++++++++++++ src/shared/i18n/fr.ts | 299 +++++++++++++++++ src/shared/i18n/index.ts | 43 +++ src/shared/i18n/ja.ts | 299 +++++++++++++++++ src/shared/i18n/zh.ts | 299 +++++++++++++++++ src/shared/types.ts | 6 + ui/src/app/app.html | 8 +- ui/src/app/app.ts | 11 +- .../connection-manager.html | 78 ++--- .../connection-manager/connection-manager.ts | 24 +- ui/src/app/connections.service.ts | 9 +- ui/src/app/dialogs/chmod-dialog.ts | 24 +- ui/src/app/dialogs/confirm-dialog.ts | 4 +- ui/src/app/dialogs/overwrite-dialog.ts | 36 +-- ui/src/app/dialogs/prompt-dialog.ts | 4 +- ui/src/app/editor-window/editor-window.html | 43 ++- ui/src/app/editor-window/editor-window.ts | 4 +- ui/src/app/file-actions.service.ts | 32 +- ui/src/app/file-icons.ts | 27 +- ui/src/app/file-panel/file-panel.html | 50 +-- ui/src/app/file-panel/file-panel.ts | 17 +- ui/src/app/format.ts | 47 ++- ui/src/app/i18n.service.ts | 61 ++++ ui/src/app/panels.service.ts | 15 +- ui/src/app/settings.service.ts | 17 + ui/src/app/status-bar/status-bar.ts | 22 +- ui/src/app/tab-bar/tab-bar.ts | 113 ++++--- ui/src/app/transfer-bar/transfer-bar.html | 8 +- ui/src/app/transfer-bar/transfer-bar.ts | 27 +- ui/src/app/transfers.service.ts | 19 +- 43 files changed, 2755 insertions(+), 322 deletions(-) create mode 100644 src/bun/i18n.ts create mode 100644 src/shared/i18n/README.md create mode 100644 src/shared/i18n/de.ts create mode 100644 src/shared/i18n/en.ts create mode 100644 src/shared/i18n/es.ts create mode 100644 src/shared/i18n/eu.ts create mode 100644 src/shared/i18n/fr.ts create mode 100644 src/shared/i18n/index.ts create mode 100644 src/shared/i18n/ja.ts create mode 100644 src/shared/i18n/zh.ts create mode 100644 ui/src/app/i18n.service.ts diff --git a/docs/architecture.md b/docs/architecture.md index 77d1d5b..79992f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,7 +154,14 @@ would float over editor windows). remembered (`FileActionsService.menuTarget`). Labels are per-platform. - `menu.ts` — macOS-only app menu, rebuilt on every connections change (the Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun - routes `close-tab` to the focused editor window first. + routes `close-tab` to the focused editor window first. Labels go through + backend `i18n.t` and the menu is rebuilt on a locale change too. +- `i18n.ts` — backend translator (native menus + thrown error messages, which + no Angular service can reach). `setLocale(setting)` caches the resolved + locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`); + `t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI. + Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds + the app menu). `context-menu.ts` labels resolve through `t()` at show-time. - `tray.ts` — static icon, click = activate main. macOS template image; Windows colored icon. No `setImage` calls ever (they lose the template treatment) and no menu (clicks arrive directly). @@ -202,6 +209,15 @@ would float over editor windows). (whose constructor spawns a workspace — fatal in secondary windows). - `connections.service.ts` — store mirror + ssh-config hosts; reloads on the `connectionsChanged` push. +- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)` + (dotted lookup, `{param}` interpolation, `{one,other}` plural maps via + `Intl.PluralRules`); missing keys fall back to English then the raw key. + **Reactive by design**: `t()` reads the `locale` signal, so every template + binding that calls it re-renders live on a language switch (same call-stack + tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()` + /`speed()` so the file list reformats on switch. `SettingsService.applyLocale` + resolves the `locale` setting (`system` → `navigator.language`) into it, on + boot and on the `settingsChanged` broadcast (so editor/manager windows follow). - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always against an explicit panel; `menuTarget` remembers the right-clicked row. - `file-icons.ts` — extension → category map driving glyphs, colors diff --git a/docs/gotchas.md b/docs/gotchas.md index f742485..f86eda2 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -85,6 +85,25 @@ touching the adjacent code. don't shrink the 400 ms debounce. - `panel.touched` gates localDir auto-navigation; `connectToken` guards against a slow connect resolving into a panel the user already reused. + +## i18n + +- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate: + it sidesteps `resolveJsonModule` differences between the Angular esbuild and + Bun builds and gives full typing. `en.ts` is the source of truth; other + locales are partial and fall back to English then the raw key. +- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what + makes live language switching work with no pipe. If you cache a translated + string in a plain field it WON'T update on switch; call `t()` in the template + (or in a signal-reading getter) instead. Same for `i18n.date()/size()`. +- Dictionary values are TS string literals: **French/other apostrophes break + single-quoted entries** (`'…c'est…'`). Use double quotes for any value + containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings); + zh/ja supply only `other`. +- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts` + go through backend `i18n.ts`, and the app menu must be rebuilt when the locale + changes (wired in index.ts `settingsChanged`), or it keeps the old language. +- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README). - **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched result.** A normal `refresh()` is masked by `busy` (input blocked), but the silent variant (poll/watch) runs while the user can navigate — without the diff --git a/src/bun/context-menu.ts b/src/bun/context-menu.ts index 8e0f05b..ff9111e 100644 --- a/src/bun/context-menu.ts +++ b/src/bun/context-menu.ts @@ -1,25 +1,21 @@ import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun"; import type { ColumnKey } from "../shared/types"; +import { t } from "./i18n"; /** Optional list columns, in display order (Name is fixed). */ -const COLUMNS: { key: ColumnKey; label: string }[] = [ - { key: "size", label: "Size" }, - { key: "mtime", label: "Modified" }, - { key: "kind", label: "Kind" }, - { key: "rights", label: "Rights" }, - { key: "owner", label: "Owner" }, - { key: "group", label: "Group" }, -]; +const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"]; -// PORTABILITY: shell-integration wording per OS +// PORTABILITY: shell-integration wording per OS (resolved at show() for locale) const IS_WIN = process.platform === "win32"; -const REVEAL_LABEL = - process.platform === "darwin" - ? "Reveal in Finder" - : IS_WIN - ? "Show in Explorer" - : "Show in File Manager"; -const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash"; +const revealLabel = (): string => + t( + process.platform === "darwin" + ? "ctx.revealFinder" + : IS_WIN + ? "ctx.revealExplorer" + : "ctx.revealFileManager", + ); +const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash"); export interface RowMenuContext { path: string; @@ -75,8 +71,8 @@ export class PanelContextMenu { /** Column picker for the list headers: checkable toggles per column. */ showColumns(visible: ColumnKey[]): void { ContextMenu.showContextMenu( - COLUMNS.map(({ key, label }) => ({ - label, + COLUMN_KEYS.map((key) => ({ + label: t(`columns.${key}`), action: `col:${key}`, checked: visible.includes(key), })), @@ -88,47 +84,45 @@ export class PanelContextMenu { if (!ctx.hasEntry || ctx.name === "..") { // empty space (or the parent row): folder-level actions - items.push({ label: "New Folder", action: "ctx-new-folder" }); - items.push({ label: "New File", action: "ctx-new-file" }); + items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" }); + items.push({ label: t("ctx.newFile"), action: "ctx-new-file" }); items.push({ type: "separator" }); - items.push({ label: "Refresh", action: "ctx-refresh" }); + items.push({ label: t("ctx.refresh"), action: "ctx-refresh" }); if (ctx.isLocal) { items.push({ type: "separator" }); - items.push({ label: REVEAL_LABEL, action: "ctx-reveal" }); + items.push({ label: revealLabel(), action: "ctx-reveal" }); } return items; } if (ctx.type === "dir") { - items.push({ label: "Open", action: "ctx-open" }); + items.push({ label: t("ctx.open"), action: "ctx-open" }); } else { if (ctx.isLocal) { - items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" }); + items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") }); } - items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" }); + items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" }); } - items.push({ label: "Copy to Other Panel", action: "ctx-copy" }); - items.push({ label: "Rename", action: "ctx-rename" }); + items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" }); + items.push({ label: t("ctx.rename"), action: "ctx-rename" }); // chmod on Windows local files only flips the read-only bit — hide the lie if (!(ctx.isLocal && IS_WIN)) { - items.push({ label: "Permissions", action: "ctx-chmod" }); + items.push({ label: t("ctx.permissions"), action: "ctx-chmod" }); } items.push({ type: "separator" }); - items.push({ label: "Copy Path", action: "ctx-copy-path" }); + items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" }); if (ctx.isLocal) { - items.push({ label: REVEAL_LABEL, action: "ctx-reveal" }); + items.push({ label: revealLabel(), action: "ctx-reveal" }); } items.push({ type: "separator" }); if (ctx.isLocal) { items.push({ - label: TRASH_LABEL, + label: trashLabel(), action: "ctx-trash", - ...(process.platform === "darwin" - ? { tooltip: "Finder's Restore is not available for items trashed from here" } - : {}), + ...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}), }); } - items.push({ label: "Delete", action: "ctx-delete" }); + items.push({ label: t("ctx.delete"), action: "ctx-delete" }); return items; } } diff --git a/src/bun/fs/registry.ts b/src/bun/fs/registry.ts index cbc7fb4..7b689f9 100644 --- a/src/bun/fs/registry.ts +++ b/src/bun/fs/registry.ts @@ -1,3 +1,4 @@ +import { t } from "../i18n"; import { LocalProvider } from "./local"; import type { FileSystemProvider } from "./provider"; @@ -15,7 +16,7 @@ export class SessionRegistry { get(sessionId: string): FileSystemProvider { const provider = this.sessions.get(sessionId); - if (!provider) throw new Error(`Session "${sessionId}" is not connected`); + if (!provider) throw new Error(t("errors.sessionNotConnected", { id: sessionId })); return provider; } @@ -27,7 +28,7 @@ export class SessionRegistry { async remove(sessionId: string): Promise { if (sessionId === LOCAL_SESSION_ID) - throw new Error("The local session cannot be disconnected"); + throw new Error(t("errors.localCannotDisconnect")); const provider = this.sessions.get(sessionId); if (!provider) return; this.sessions.delete(sessionId); diff --git a/src/bun/fs/sftp.ts b/src/bun/fs/sftp.ts index 6ae4fef..2f2c70a 100644 --- a/src/bun/fs/sftp.ts +++ b/src/bun/fs/sftp.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2"; import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types"; +import { t } from "../i18n"; import { expandTilde } from "../store/ssh-config"; import type { FileSystemProvider } from "./provider"; @@ -63,7 +64,7 @@ export class SftpProvider implements FileSystemProvider { connectConfig.password = config.auth.password; break; case "agent": - if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)"); + if (!agentSock) throw new Error(t("errors.sshAgentUnavailable")); connectConfig.agent = agentSock; break; case "key": { @@ -73,7 +74,7 @@ export class SftpProvider implements FileSystemProvider { connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath)); } catch { if (!agentSock) - throw new Error(`identity file not found: ${config.auth.privateKeyPath}`); + throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath })); } if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase; else if (agentSock) connectConfig.agent = agentSock; diff --git a/src/bun/i18n.ts b/src/bun/i18n.ts new file mode 100644 index 0000000..2259880 --- /dev/null +++ b/src/bun/i18n.ts @@ -0,0 +1,38 @@ +import { DICTS, type Messages, resolveLocale } from "../shared/i18n"; +import type { AppSettings, SupportedLocale } from "../shared/types"; + +// Backend-side translator for the native menus and thrown error messages +// (the Angular I18nService can't reach OS chrome). The locale is cached and +// refreshed from settings (see index.ts wiring); no signals here. +let locale: SupportedLocale = "en"; + +/** OS locale for the "system" setting, e.g. "es-ES" -> resolves to "es". */ +function osLocale(): string { + try { + return new Intl.DateTimeFormat().resolvedOptions().locale; + } catch { + return "en"; + } +} + +export function setLocale(setting: AppSettings["locale"]): void { + locale = resolveLocale(setting, osLocale()); +} + +function lookup(dict: Messages, key: string): string | undefined { + let node: string | Messages | undefined = dict; + for (const part of key.split(".")) { + if (typeof node !== "object") return undefined; + node = node[part]; + } + return typeof node === "string" ? node : undefined; +} + +/** Translate a key with {name} interpolation; falls back to English then the key. */ +export function t(key: string, params?: Record): string { + const value = lookup(DICTS[locale], key) ?? lookup(DICTS.en, key) ?? key; + if (!params) return value; + return value.replace(/\{(\w+)\}/g, (_, name: string) => + name in params ? String(params[name]) : `{${name}}`, + ); +} diff --git a/src/bun/index.ts b/src/bun/index.ts index b3348b2..57bf3d4 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -8,6 +8,7 @@ import { EditorWindows } from "./editor-windows"; import { SessionRegistry } from "./fs/registry"; import { LocalDirWatcher } from "./fs/watcher"; import { ManagerWindow } from "./manager-window"; +import { setLocale as setBackendLocale } from "./i18n"; import { applyApplicationMenu } from "./menu"; import { createAppRPC } from "./rpc-handlers"; import { ConnectionStore } from "./store/connections"; @@ -64,6 +65,7 @@ const url = await getMainViewUrl(); // window's preload so the first paint is already themed (no flash). Kept // fresh via the saveSettings flow below. let currentSettings = await settings.load(); +setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language const isMac = process.platform === "darwin"; @@ -115,7 +117,12 @@ const manager = new ManagerWindow({ events.openEditor = (ctx) => editors.open(ctx); events.settingsChanged = (s) => { + const localeChanged = s.locale !== currentSettings.locale; currentSettings = s; // future windows' preloads bake the fresh theme + if (localeChanged) { + setBackendLocale(s.locale); + void applyApplicationMenu(store); // relabel the native menu in the new language + } editors.broadcastSettings(s); manager.broadcastSettings(s); }; diff --git a/src/bun/menu.ts b/src/bun/menu.ts index ea0b6d4..274cb50 100644 --- a/src/bun/menu.ts +++ b/src/bun/menu.ts @@ -1,6 +1,7 @@ import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import type { SavedConnection } from "../shared/types"; +import { t } from "./i18n"; import type { ConnectionStore } from "./store/connections"; /** @@ -27,37 +28,37 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise { try { const st = await fs.stat(path); if (st.type === "dir") return; - throw new Error(`"${path}" exists and is not a directory`); + throw new Error(t("errors.notADirectory", { path })); } catch { await fs.mkdir(path); } @@ -235,7 +236,7 @@ export class TransferEngine { if (sameFs) { for (const file of files) { if (file.srcPath === file.dstPath) { - throw new Error(`"${file.name}": source and destination are the same file`); + throw new Error(t("errors.sameFileTransfer", { name: file.name })); } } } @@ -343,10 +344,10 @@ export class TransferEngine { if (samePath) { throw new Error( - isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file", + isDir ? t("errors.folderOntoItself") : t("errors.sameFileNoName"), ); } - throw new Error("Cannot copy a folder into itself"); + throw new Error(t("errors.folderIntoItself")); } /** diff --git a/src/shared/i18n/README.md b/src/shared/i18n/README.md new file mode 100644 index 0000000..a7edd9b --- /dev/null +++ b/src/shared/i18n/README.md @@ -0,0 +1,35 @@ +# Translations + +`en.ts` is the **source of truth**. Every other locale mirrors its key shape; +any missing key falls back to English at lookup time (see `index.ts` + +`I18nService`/backend `i18n.ts`), so partial translations are safe. + +## Adding / editing strings + +1. Add the key to `en.ts` (nested namespace, `{param}` interpolation). For + plurals, use a `{ one, other }` map instead of a string — the runtime picks + the CLDR category via `Intl.PluralRules` (languages without plural, e.g. zh + and ja, only need `other`). +2. Reference it as `i18n.t('ns.key', { param })` in the UI (or backend `t(...)` + for native menus / thrown errors). +3. Add the translation to the other locale files as convenient — until then + English shows through. + +The format is kept **Transloco-compatible** (nested namespaces, `{param}`) so +swapping in a real i18n library later is a drop-in of these dictionaries. + +## Review status + +| Locale | File | Status | +|---|---|---| +| English | `en.ts` | Source | +| Spanish | `es.ts` | Full draft | +| French | `fr.ts` | Full draft | +| German | `de.ts` | Full draft | +| Chinese (Simplified) | `zh.ts` | **AI draft — needs native review** | +| Japanese | `ja.ts` | **AI draft — needs native review** | +| Basque | `eu.ts` | **AI draft — needs native review (highest priority; machine/AI Basque is unreliable)** | + +Native-speaker review is especially important for Basque and the CJK locales: +grammatical agreement, counters/measure words, and UI-context word choice are +easy to get subtly wrong. diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts new file mode 100644 index 0000000..81d3dc4 --- /dev/null +++ b/src/shared/i18n/de.ts @@ -0,0 +1,299 @@ +// German translations. Missing keys fall back to English (see en.ts / index.ts). +export const de = { + common: { + cancel: "Abbrechen", + save: "Speichern", + close: "Schließen", + delete: "Löschen", + rename: "Umbenennen", + create: "Erstellen", + apply: "Anwenden", + choose: "Wählen…", + connect: "Verbinden", + local: "Lokal", + }, + app: { + notConnectedTitle: "Nicht mit der App verbunden", + notConnectedBody: "Diese Seite läuft in einem normalen Browser. Starte die Desktop-App, um sie zu verwenden.", + managerOpen: "Der Verbindungsmanager ist geöffnet.", + showConnections: "Verbindungen anzeigen", + }, + tabBar: { + connections: "Verbindungen", + manageConnections: "Verbindungen verwalten", + newTab: "Neuer Tab ({mod}T)", + closeTab: "Tab schließen ({mod}W)", + options: "Optionen", + }, + options: { + title: "Optionen", + language: "Sprache", + system: "System", + theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" }, + tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, + accent: { label: "Akzent" }, + accentNames: { + graphite: "Graphit", + blue: "Blau", + indigo: "Indigo", + purple: "Lila", + rose: "Rosa", + orange: "Orange", + green: "Grün", + teal: "Petrol", + }, + fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" }, + interface: { label: "Oberfläche", compact: "Kompakt", default: "Normal", large: "Groß" }, + speedUnit: { label: "Geschwindigkeitseinheit" }, + transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" }, + atOnce: "Gleichzeitig", + editor: { label: "Editor", builtin: "Integriert", custom: "Benutzerdefiniert" }, + systemDefault: "Systemstandard", + resetDefault: "Auf Standard zurücksetzen", + finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" }, + }, + kinds: { + folder: "Ordner", + symlink: "Symbolischer Link", + file: "Datei", + image: "Bild", + audio: "Audio", + video: "Video", + archive: "Archiv", + code: "Code", + document: "Dokument", + }, + columns: { + name: "Name", + size: "Größe", + mtime: "Geändert", + kind: "Typ", + rights: "Rechte", + owner: "Eigentümer", + group: "Gruppe", + }, + panel: { + choosePanel: "Wählen, was dieses Panel anzeigt", + copy: "Kopieren", + copyToRight: "In rechtes Panel kopieren (F5)", + copyToLeft: "In linkes Panel kopieren (F5)", + edit: "Bearbeiten", + editFile: "Datei bearbeiten (F4)", + rename: "Umbenennen (F2)", + newFolder: "Neuer Ordner (F7)", + newFile: "Neue Datei", + permissions: "Berechtigungen", + delete: "Löschen", + deleteHint: "Löschen (F8 / Entf)", + parentDir: "Übergeordneter Ordner (Rücktaste)", + refresh: "Aktualisieren", + clearFilter: "Filter löschen (Esc)", + sshConfig: "~/.ssh/config", + connectingTo: "Verbinde mit {name}…", + emptyDir: "Leerer Ordner", + noMatches: "Keine Treffer", + matches: { one: "{count} Treffer · Esc", other: "{count} Treffer · Esc" }, + }, + connMgr: { + title: "Verbindungen", + importWinscp: "Aus WinSCP importieren…", + importWinscpHint: "Sitzungen aus einem WinSCP.ini-Export importieren", + newConnection: "Verbindung", + newConnectionHint: "Neue Verbindung", + newStack: "Gruppe", + newStackHint: "Neue Gruppe", + expand: "Aufklappen", + collapse: "Zuklappen", + renameStack: "Gruppe umbenennen", + deleteStack: "Gruppe löschen", + openStack: "Gruppe in Tabs öffnen", + empty: "leer", + connectionsHeader: "Verbindungen", + noneTitle: "Noch keine gespeicherten Verbindungen.", + noneBody: "Erstelle eine oder importiere deine WinSCP-Sitzungen.", + name: "Name", + namePlaceholder: "mein Server", + host: "Host", + port: "Port", + stack: "Gruppe", + stackNone: "(keine)", + username: "Benutzername", + remotePath: "Entfernter Pfad (optional)", + remotePathPlaceholder: "Home-Verzeichnis", + auth: "Authentifizierung", + authPassword: "Passwort", + authKey: "Privater Schlüssel", + password: "Passwort", + privateKeyFile: "Datei mit privatem Schlüssel", + passphrase: "Passphrase (optional)", + localDir: "Lokales Verzeichnis (optional)", + localDirPlaceholder: "~/projekte/mein-server", + localDirHint: "Das andere Panel öffnet sich hier, wenn es auf Lokal steht und nicht navigiert wurde.", + more: "Mehr…", + less: "Weniger…", + selectPrompt: "Wähle eine Verbindung oder erstelle eine neue.", + newStackTitle: "Neue Gruppe", + newStackPrompt: "Gruppenname", + renameStackTitle: "Gruppe umbenennen", + deleteStackTitle: "Gruppe löschen", + deleteStackBody: 'Gruppe „{name}" löschen? Ihre Verbindungen bleiben, ohne Gruppe.', + deleteConnectionTitle: "Verbindung löschen", + deleteConnectionBody: '„{name}" löschen?', + }, + editor: { + noFileTitle: "Keine Datei zum Bearbeiten", + noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.", + wrap: "Umbruch", + wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)", + findHint: "Suchen ({mod}F)", + findPlaceholder: "In Datei suchen", + prevMatch: "Vorheriger Treffer (⇧↵)", + nextMatch: "Nächster Treffer (↵)", + closeHint: "Schließen (Esc)", + disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.", + cantOpen: "{name} kann nicht geöffnet werden", + closeWindow: "Fenster schließen", + shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen", + lines: { one: "{count} Zeile", other: "{count} Zeilen" }, + close: "Schließen", + save: "Speichern", + saveChangesTitle: 'Änderungen an „{name}" speichern?', + saveChangesBody: "Deine Änderungen gehen verloren, wenn du sie nicht speicherst.", + dontSave: "Nicht speichern", + countLabel: "{current} von {total}", + noMatches: "Keine Treffer", + }, + transfer: { + summary: "{active} aktiv · {finished} abgeschlossen", + clearFinished: "Abgeschlossene löschen", + cancel: "Abbrechen", + dismiss: "Verwerfen", + showDetails: "Übertragungsdetails anzeigen", + copying: "Kopiere {name} · {percent}%{speed}", + queued: "{count} in Warteschlange", + failed: { one: "{count} Übertragung fehlgeschlagen", other: "{count} Übertragungen fehlgeschlagen" }, + finished: "Übertragungen abgeschlossen", + ready: "Bereit", + items: "{count} Elemente", + selected: "{count} ausgewählt", + queuedState: "In Warteschlange", + scanning: "Analysiere…", + failedState: "Fehlgeschlagen", + cancelled: "Abgebrochen", + completedFiles: { one: "{count} Datei · {size}", other: "{count} Dateien · {size}" }, + left: "{eta} übrig", + }, + dialogs: { + chmodTitle: "Berechtigungen", + chmodItems: "{count} Elemente", + read: "Lesen", + write: "Schreiben", + execute: "Ausführen", + roleOwner: "Eigentümer", + roleGroup: "Gruppe", + roleOthers: "Andere", + octal: "Oktal", + mergeFolder: "Ordner zusammenführen", + replace: "Ersetzen", + merge: "Zusammenführen", + mergeAll: "Alle zusammenführen", + replaceAll: "Alle ersetzen", + skip: "Überspringen", + skipAll: "Alle überspringen", + ofTotal: "{index} von {total}", + overwriteDirBody: "Am Ziel existiert bereits ein Ordner mit diesem Namen. Zusammenführen kann Dateien darin ersetzen.", + overwriteFileBody: "Am Ziel existiert bereits eine Datei mit diesem Namen.", + source: "Quelle", + destination: "Ziel", + newer: "neuer", + }, + toasts: { + movedToTrash: '„{name}" in den Papierkorb verschoben', + editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch", + nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)", + nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen", + sameFolder: "Beide Panels zeigen denselben Ordner", + sameFile: '„{name}" übersprungen — es ist dieselbe Datei', + typeMismatch: '„{name}" übersprungen — ein anderer Elementtyp ist im Weg', + transferFailed: 'Übertragung „{name}" fehlgeschlagen: {error}', + unknownError: "unbekannter Fehler", + saved: "{name} gespeichert", + disconnectedReason: "{title}: getrennt ({reason})", + imported: { one: "{count} Verbindung importiert", other: "{count} Verbindungen importiert" }, + importedIntoStacks: { one: " in {count} neue Gruppe", other: " in {count} neue Gruppen" }, + importedSkipped: " ({skipped} übersprungen)", + leftoverTitle: "Übrig gebliebene bearbeitete Dateien", + leftoverMore: ", +{count} weitere", + leftoverBody: { + one: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Datei ({names}). Löschen?", + other: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Dateien ({names}). Löschen?", + }, + leftoverClear: "Löschen", + }, + fileActions: { + deleteTitle: "Löschen", + deleteBody: "{what} löschen? Ordner werden rekursiv gelöscht. Dies kann nicht rückgängig gemacht werden.", + deleteConfirm: "Löschen", + renameTitle: '„{name}" umbenennen', + renameConfirm: "Umbenennen", + newFolderTitle: "Neuer Ordner", + newFolderPlaceholder: "Ordnername", + newFileTitle: "Neue Datei", + newFilePlaceholder: "Dateiname", + createConfirm: "Erstellen", + itemsCount: "{count} Elemente", + }, + menu: { + about: "Über {app}", + quit: "Beenden", + file: "Datei", + newTab: "Neuer Tab", + closeTab: "Tab schließen", + newFolder: "Neuer Ordner", + newFile: "Neue Datei", + editFile: "Datei bearbeiten", + connect: "Verbinden", + connectionManager: "Verbindungsmanager…", + edit: "Bearbeiten", + view: "Ansicht", + refresh: "Aktualisieren", + window: "Fenster", + }, + ctx: { + size: "Größe", + modified: "Geändert", + kind: "Typ", + rights: "Rechte", + owner: "Eigentümer", + group: "Gruppe", + revealFinder: "Im Finder anzeigen", + revealExplorer: "Im Explorer anzeigen", + revealFileManager: "Im Dateimanager anzeigen", + moveToTrash: "In den Papierkorb", + moveToRecycleBin: "In den Papierkorb", + newFolder: "Neuer Ordner", + newFile: "Neue Datei", + refresh: "Aktualisieren", + open: "Öffnen", + openDefault: "Mit Standard-App öffnen", + edit: "Bearbeiten", + copyToOther: "In anderes Panel kopieren", + rename: "Umbenennen", + permissions: "Berechtigungen", + copyPath: "Pfad kopieren", + delete: "Löschen", + columns: "Spalten", + trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar", + }, + errors: { + sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)", + identityFileNotFound: "Identitätsdatei nicht gefunden: {path}", + sessionNotConnected: 'Sitzung „{id}" ist nicht verbunden', + localCannotDisconnect: "Die lokale Sitzung kann nicht getrennt werden", + notADirectory: '„{path}" existiert und ist kein Ordner', + sameFileTransfer: '„{name}": Quelle und Ziel sind dieselbe Datei', + sameFileNoName: "Quelle und Ziel sind dieselbe Datei", + folderOntoItself: "Ein Ordner kann nicht auf sich selbst kopiert werden", + folderIntoItself: "Ein Ordner kann nicht in sich selbst kopiert werden", + }, +} as const; diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts new file mode 100644 index 0000000..f9fc33d --- /dev/null +++ b/src/shared/i18n/en.ts @@ -0,0 +1,303 @@ +// English is the source of truth. Other locales mirror this shape; missing keys +// fall back to English at lookup time. Placeholders use {name} interpolation. +// Keep Transloco-compatible (nested namespaces, {param}) so a later migration +// to a real i18n library is a drop-in of these dictionaries. + +export const en = { + common: { + cancel: "Cancel", + save: "Save", + close: "Close", + delete: "Delete", + rename: "Rename", + create: "Create", + apply: "Apply", + choose: "Choose…", + connect: "Connect", + local: "Local", + }, + app: { + notConnectedTitle: "Not connected to the app", + notConnectedBody: "This page is running in a plain browser. Launch the desktop app to use it.", + managerOpen: "The connection manager is open.", + showConnections: "Show Connections", + }, + tabBar: { + connections: "Connections", + manageConnections: "Manage connections", + newTab: "New tab ({mod}T)", + closeTab: "Close tab ({mod}W)", + options: "Options", + }, + options: { + title: "Options", + language: "Language", + system: "System", + theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" }, + tone: { label: "Tone", dark: "Dark", darker: "Darker" }, + accent: { label: "Accent" }, + accentNames: { + graphite: "Graphite", + blue: "Blue", + indigo: "Indigo", + purple: "Purple", + rose: "Rose", + orange: "Orange", + green: "Green", + teal: "Teal", + }, + fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" }, + interface: { label: "Interface", compact: "Compact", default: "Default", large: "Large" }, + speedUnit: { label: "Speed unit" }, + transfers: { label: "Transfers", single: "Single", multi: "Multi" }, + atOnce: "At once", + editor: { label: "Editor", builtin: "Built-in", custom: "Custom" }, + systemDefault: "System default", + resetDefault: "Reset to system default", + finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" }, + }, + kinds: { + folder: "Folder", + symlink: "Symlink", + file: "File", + image: "Image", + audio: "Audio", + video: "Video", + archive: "Archive", + code: "Code", + document: "Document", + }, + columns: { + name: "Name", + size: "Size", + mtime: "Modified", + kind: "Kind", + rights: "Rights", + owner: "Owner", + group: "Group", + }, + panel: { + choosePanel: "Choose what this panel shows", + copy: "Copy", + copyToRight: "Copy to right panel (F5)", + copyToLeft: "Copy to left panel (F5)", + edit: "Edit", + editFile: "Edit file (F4)", + rename: "Rename (F2)", + newFolder: "New folder (F7)", + newFile: "New file", + permissions: "Permissions", + delete: "Delete", + deleteHint: "Delete (F8 / Del)", + parentDir: "Parent directory (Backspace)", + refresh: "Refresh", + clearFilter: "Clear filter (Esc)", + sshConfig: "~/.ssh/config", + connectingTo: "Connecting to {name}…", + emptyDir: "Empty directory", + noMatches: "No matches", + matches: { one: "{count} match · Esc", other: "{count} matches · Esc" }, + }, + connMgr: { + title: "Connections", + importWinscp: "Import from WinSCP…", + importWinscpHint: "Import sessions from a WinSCP.ini export", + newConnection: "Connection", + newConnectionHint: "New connection", + newStack: "Stack", + newStackHint: "New stack", + expand: "Expand", + collapse: "Collapse", + renameStack: "Rename stack", + deleteStack: "Delete stack", + openStack: "Open stack in tabs", + empty: "empty", + connectionsHeader: "Connections", + noneTitle: "No saved connections yet.", + noneBody: "Create one, or import your WinSCP sessions.", + name: "Name", + namePlaceholder: "my server", + host: "Host", + port: "Port", + stack: "Stack", + stackNone: "(none)", + username: "Username", + remotePath: "Remote path (optional)", + remotePathPlaceholder: "home directory", + auth: "Auth", + authPassword: "Password", + authKey: "Private key", + password: "Password", + privateKeyFile: "Private key file", + passphrase: "Passphrase (optional)", + localDir: "Local directory (optional)", + localDirPlaceholder: "~/projects/my-server", + localDirHint: "The other panel opens here when it's on Local and hasn't been navigated.", + more: "More…", + less: "Less…", + selectPrompt: "Select a connection or create a new one.", + newStackTitle: "New stack", + newStackPrompt: "stack name", + renameStackTitle: "Rename stack", + deleteStackTitle: "Delete stack", + deleteStackBody: 'Delete stack "{name}"? Its connections stay, ungrouped.', + deleteConnectionTitle: "Delete connection", + deleteConnectionBody: 'Delete "{name}"?', + }, + editor: { + noFileTitle: "No file to edit", + noFileBody: "This window was opened without an editor context. Launch files from the app.", + wrap: "Wrap", + wrapHint: "Wrap long lines — hides line numbers ({alt}Z)", + findHint: "Find ({mod}F)", + findPlaceholder: "Find in file", + prevMatch: "Previous match (⇧↵)", + nextMatch: "Next match (↵)", + closeHint: "Close (Esc)", + disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.", + cantOpen: "Can't open {name}", + closeWindow: "Close Window", + shortcuts: "{mod}S save · {mod}F find · {mod}W close", + lines: { one: "{count} line", other: "{count} lines" }, + close: "Close", + save: "Save", + saveChangesTitle: 'Save changes to "{name}"?', + saveChangesBody: "Your changes will be lost if you don't save them.", + dontSave: "Don't Save", + countLabel: "{current} of {total}", + noMatches: "No matches", + }, + transfer: { + summary: "{active} active · {finished} finished", + clearFinished: "Clear finished", + cancel: "Cancel", + dismiss: "Dismiss", + showDetails: "Show transfer details", + copying: "Copying {name} · {percent}%{speed}", + queued: "{count} queued", + failed: { one: "{count} transfer failed", other: "{count} transfers failed" }, + finished: "Transfers finished", + ready: "Ready", + items: "{count} items", + selected: "{count} selected", + queuedState: "Queued", + scanning: "Scanning…", + failedState: "Failed", + cancelled: "Cancelled", + completedFiles: { one: "{count} file · {size}", other: "{count} files · {size}" }, + left: "{eta} left", + }, + dialogs: { + chmodTitle: "Permissions", + chmodItems: "{count} items", + read: "Read", + write: "Write", + execute: "Execute", + roleOwner: "Owner", + roleGroup: "Group", + roleOthers: "Others", + octal: "Octal", + mergeFolder: "Merge folder", + replace: "Replace", + merge: "Merge", + mergeAll: "Merge all", + replaceAll: "Replace all", + skip: "Skip", + skipAll: "Skip all", + ofTotal: "{index} of {total}", + overwriteDirBody: "The destination already has a folder with this name. Merging may replace files inside it.", + overwriteFileBody: "The destination already has a file with this name.", + source: "Source", + destination: "Destination", + newer: "newer", + }, + toasts: { + movedToTrash: 'Moved "{name}" to Trash', + editingExternal: "Editing {name} — saves upload automatically", + nothingToCopy: "Nothing to copy (symlinks are skipped)", + nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped", + sameFolder: "Both panels show the same folder", + sameFile: 'Skipped "{name}" — it is the same file', + typeMismatch: 'Skipped "{name}" — a different kind of item is in the way', + transferFailed: 'Transfer "{name}" failed: {error}', + unknownError: "unknown error", + saved: "Saved {name}", + disconnectedReason: "{title}: disconnected ({reason})", + imported: { one: "Imported {count} connection", other: "Imported {count} connections" }, + importedIntoStacks: { one: " into {count} new stack", other: " into {count} new stacks" }, + importedSkipped: " ({skipped} skipped)", + leftoverTitle: "Leftover edited files", + leftoverBody: { + one: "A previous session left {count} temporary edited file behind ({names}). Clear them?", + other: "A previous session left {count} temporary edited files behind ({names}). Clear them?", + }, + leftoverMore: ", +{count} more", + leftoverClear: "Clear", + }, + fileActions: { + deleteTitle: "Delete", + deleteBody: "Delete {what}? Folders are deleted recursively. This cannot be undone.", + deleteConfirm: "Delete", + renameTitle: 'Rename "{name}"', + renameConfirm: "Rename", + newFolderTitle: "New folder", + newFolderPlaceholder: "folder name", + newFileTitle: "New file", + newFilePlaceholder: "file name", + createConfirm: "Create", + itemsCount: "{count} items", + }, + menu: { + about: "About {app}", + quit: "Quit", + file: "File", + newTab: "New Tab", + closeTab: "Close Tab", + newFolder: "New Folder", + newFile: "New File", + editFile: "Edit File", + connect: "Connect", + connectionManager: "Connection Manager…", + edit: "Edit", + view: "View", + refresh: "Refresh", + window: "Window", + }, + ctx: { + size: "Size", + modified: "Modified", + kind: "Kind", + rights: "Rights", + owner: "Owner", + group: "Group", + revealFinder: "Reveal in Finder", + revealExplorer: "Show in Explorer", + revealFileManager: "Show in File Manager", + moveToTrash: "Move to Trash", + moveToRecycleBin: "Move to Recycle Bin", + newFolder: "New Folder", + newFile: "New File", + refresh: "Refresh", + open: "Open", + openDefault: "Open with the default application", + edit: "Edit", + copyToOther: "Copy to Other Panel", + rename: "Rename", + permissions: "Permissions", + copyPath: "Copy Path", + delete: "Delete", + columns: "Columns", + trashTooltip: "Finder's Restore is not available for items trashed from here", + }, + errors: { + sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)", + identityFileNotFound: "identity file not found: {path}", + sessionNotConnected: 'Session "{id}" is not connected', + localCannotDisconnect: "The local session cannot be disconnected", + notADirectory: '"{path}" exists and is not a directory', + sameFileTransfer: '"{name}": source and destination are the same file', + sameFileNoName: "Source and destination are the same file", + folderOntoItself: "Cannot copy a folder onto itself", + folderIntoItself: "Cannot copy a folder into itself", + }, +} as const; diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts new file mode 100644 index 0000000..3165719 --- /dev/null +++ b/src/shared/i18n/es.ts @@ -0,0 +1,299 @@ +// Spanish translations. Missing keys fall back to English (see en.ts / index.ts). +export const es = { + common: { + cancel: "Cancelar", + save: "Guardar", + close: "Cerrar", + delete: "Eliminar", + rename: "Renombrar", + create: "Crear", + apply: "Aplicar", + choose: "Elegir…", + connect: "Conectar", + local: "Local", + }, + app: { + notConnectedTitle: "No conectado a la aplicación", + notConnectedBody: "Esta página se ejecuta en un navegador normal. Abre la aplicación de escritorio para usarla.", + managerOpen: "El gestor de conexiones está abierto.", + showConnections: "Mostrar conexiones", + }, + tabBar: { + connections: "Conexiones", + manageConnections: "Gestionar conexiones", + newTab: "Nueva pestaña ({mod}T)", + closeTab: "Cerrar pestaña ({mod}W)", + options: "Opciones", + }, + options: { + title: "Opciones", + language: "Idioma", + system: "Sistema", + theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" }, + tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, + accent: { label: "Acento" }, + accentNames: { + graphite: "Grafito", + blue: "Azul", + indigo: "Índigo", + purple: "Morado", + rose: "Rosa", + orange: "Naranja", + green: "Verde", + teal: "Turquesa", + }, + fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" }, + interface: { label: "Interfaz", compact: "Compacta", default: "Normal", large: "Grande" }, + speedUnit: { label: "Unidad de velocidad" }, + transfers: { label: "Transferencias", single: "Una", multi: "Varias" }, + atOnce: "A la vez", + editor: { label: "Editor", builtin: "Integrado", custom: "Personalizado" }, + systemDefault: "Predeterminado del sistema", + resetDefault: "Restablecer al predeterminado", + finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" }, + }, + kinds: { + folder: "Carpeta", + symlink: "Enlace simbólico", + file: "Archivo", + image: "Imagen", + audio: "Audio", + video: "Vídeo", + archive: "Archivo comprimido", + code: "Código", + document: "Documento", + }, + columns: { + name: "Nombre", + size: "Tamaño", + mtime: "Modificado", + kind: "Tipo", + rights: "Permisos", + owner: "Propietario", + group: "Grupo", + }, + panel: { + choosePanel: "Elige qué muestra este panel", + copy: "Copiar", + copyToRight: "Copiar al panel derecho (F5)", + copyToLeft: "Copiar al panel izquierdo (F5)", + edit: "Editar", + editFile: "Editar archivo (F4)", + rename: "Renombrar (F2)", + newFolder: "Nueva carpeta (F7)", + newFile: "Nuevo archivo", + permissions: "Permisos", + delete: "Eliminar", + deleteHint: "Eliminar (F8 / Supr)", + parentDir: "Carpeta superior (Retroceso)", + refresh: "Actualizar", + clearFilter: "Borrar filtro (Esc)", + sshConfig: "~/.ssh/config", + connectingTo: "Conectando con {name}…", + emptyDir: "Carpeta vacía", + noMatches: "Sin coincidencias", + matches: { one: "{count} coincidencia · Esc", other: "{count} coincidencias · Esc" }, + }, + connMgr: { + title: "Conexiones", + importWinscp: "Importar de WinSCP…", + importWinscpHint: "Importar sesiones de una exportación WinSCP.ini", + newConnection: "Conexión", + newConnectionHint: "Nueva conexión", + newStack: "Grupo", + newStackHint: "Nuevo grupo", + expand: "Expandir", + collapse: "Contraer", + renameStack: "Renombrar grupo", + deleteStack: "Eliminar grupo", + openStack: "Abrir grupo en pestañas", + empty: "vacío", + connectionsHeader: "Conexiones", + noneTitle: "Aún no hay conexiones guardadas.", + noneBody: "Crea una o importa tus sesiones de WinSCP.", + name: "Nombre", + namePlaceholder: "mi servidor", + host: "Servidor", + port: "Puerto", + stack: "Grupo", + stackNone: "(ninguno)", + username: "Usuario", + remotePath: "Ruta remota (opcional)", + remotePathPlaceholder: "carpeta de inicio", + auth: "Autenticación", + authPassword: "Contraseña", + authKey: "Clave privada", + password: "Contraseña", + privateKeyFile: "Archivo de clave privada", + passphrase: "Frase de contraseña (opcional)", + localDir: "Carpeta local (opcional)", + localDirPlaceholder: "~/proyectos/mi-servidor", + localDirHint: "El otro panel se abre aquí cuando está en Local y no se ha navegado.", + more: "Más…", + less: "Menos…", + selectPrompt: "Selecciona una conexión o crea una nueva.", + newStackTitle: "Nuevo grupo", + newStackPrompt: "nombre del grupo", + renameStackTitle: "Renombrar grupo", + deleteStackTitle: "Eliminar grupo", + deleteStackBody: '¿Eliminar el grupo "{name}"? Sus conexiones se mantienen, sin agrupar.', + deleteConnectionTitle: "Eliminar conexión", + deleteConnectionBody: '¿Eliminar "{name}"?', + }, + editor: { + noFileTitle: "Ningún archivo que editar", + noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.", + wrap: "Ajustar", + wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)", + findHint: "Buscar ({mod}F)", + findPlaceholder: "Buscar en el archivo", + prevMatch: "Coincidencia anterior (⇧↵)", + nextMatch: "Coincidencia siguiente (↵)", + closeHint: "Cerrar (Esc)", + disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.", + cantOpen: "No se puede abrir {name}", + closeWindow: "Cerrar ventana", + shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar", + lines: { one: "{count} línea", other: "{count} líneas" }, + close: "Cerrar", + save: "Guardar", + saveChangesTitle: '¿Guardar los cambios en "{name}"?', + saveChangesBody: "Perderás tus cambios si no los guardas.", + dontSave: "No guardar", + countLabel: "{current} de {total}", + noMatches: "Sin coincidencias", + }, + transfer: { + summary: "{active} activas · {finished} completadas", + clearFinished: "Limpiar completadas", + cancel: "Cancelar", + dismiss: "Descartar", + showDetails: "Ver detalles de transferencias", + copying: "Copiando {name} · {percent}%{speed}", + queued: "{count} en cola", + failed: { one: "{count} transferencia fallida", other: "{count} transferencias fallidas" }, + finished: "Transferencias completadas", + ready: "Listo", + items: "{count} elementos", + selected: "{count} seleccionados", + queuedState: "En cola", + scanning: "Analizando…", + failedState: "Fallida", + cancelled: "Cancelada", + completedFiles: { one: "{count} archivo · {size}", other: "{count} archivos · {size}" }, + left: "{eta} restante", + }, + dialogs: { + chmodTitle: "Permisos", + chmodItems: "{count} elementos", + read: "Lectura", + write: "Escritura", + execute: "Ejecución", + roleOwner: "Propietario", + roleGroup: "Grupo", + roleOthers: "Otros", + octal: "Octal", + mergeFolder: "Combinar carpeta", + replace: "Reemplazar", + merge: "Combinar", + mergeAll: "Combinar todo", + replaceAll: "Reemplazar todo", + skip: "Omitir", + skipAll: "Omitir todo", + ofTotal: "{index} de {total}", + overwriteDirBody: "El destino ya tiene una carpeta con este nombre. Combinar puede reemplazar archivos dentro.", + overwriteFileBody: "El destino ya tiene un archivo con este nombre.", + source: "Origen", + destination: "Destino", + newer: "más reciente", + }, + toasts: { + movedToTrash: 'Se movió "{name}" a la papelera', + editingExternal: "Editando {name} — los cambios se suben automáticamente", + nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)", + nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto", + sameFolder: "Ambos paneles muestran la misma carpeta", + sameFile: 'Se omitió "{name}" — es el mismo archivo', + typeMismatch: 'Se omitió "{name}" — hay otro tipo de elemento en el camino', + transferFailed: 'La transferencia "{name}" falló: {error}', + unknownError: "error desconocido", + saved: "Se guardó {name}", + disconnectedReason: "{title}: desconectado ({reason})", + imported: { one: "Se importó {count} conexión", other: "Se importaron {count} conexiones" }, + importedIntoStacks: { one: " en {count} grupo nuevo", other: " en {count} grupos nuevos" }, + importedSkipped: " ({skipped} omitidas)", + leftoverTitle: "Archivos editados sobrantes", + leftoverMore: ", +{count} más", + leftoverBody: { + one: "Una sesión anterior dejó {count} archivo editado temporal ({names}). ¿Eliminarlo?", + other: "Una sesión anterior dejó {count} archivos editados temporales ({names}). ¿Eliminarlos?", + }, + leftoverClear: "Eliminar", + }, + fileActions: { + deleteTitle: "Eliminar", + deleteBody: "¿Eliminar {what}? Las carpetas se eliminan de forma recursiva. Esto no se puede deshacer.", + deleteConfirm: "Eliminar", + renameTitle: 'Renombrar "{name}"', + renameConfirm: "Renombrar", + newFolderTitle: "Nueva carpeta", + newFolderPlaceholder: "nombre de la carpeta", + newFileTitle: "Nuevo archivo", + newFilePlaceholder: "nombre del archivo", + createConfirm: "Crear", + itemsCount: "{count} elementos", + }, + menu: { + about: "Acerca de {app}", + quit: "Salir", + file: "Archivo", + newTab: "Nueva pestaña", + closeTab: "Cerrar pestaña", + newFolder: "Nueva carpeta", + newFile: "Nuevo archivo", + editFile: "Editar archivo", + connect: "Conectar", + connectionManager: "Gestor de conexiones…", + edit: "Edición", + view: "Ver", + refresh: "Actualizar", + window: "Ventana", + }, + ctx: { + size: "Tamaño", + modified: "Modificado", + kind: "Tipo", + rights: "Permisos", + owner: "Propietario", + group: "Grupo", + revealFinder: "Mostrar en Finder", + revealExplorer: "Mostrar en el Explorador", + revealFileManager: "Mostrar en el gestor de archivos", + moveToTrash: "Mover a la papelera", + moveToRecycleBin: "Mover a la papelera de reciclaje", + newFolder: "Nueva carpeta", + newFile: "Nuevo archivo", + refresh: "Actualizar", + open: "Abrir", + openDefault: "Abrir con la aplicación predeterminada", + edit: "Editar", + copyToOther: "Copiar al otro panel", + rename: "Renombrar", + permissions: "Permisos", + copyPath: "Copiar ruta", + delete: "Eliminar", + columns: "Columnas", + trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí", + }, + errors: { + sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)", + identityFileNotFound: "archivo de identidad no encontrado: {path}", + sessionNotConnected: 'La sesión "{id}" no está conectada', + localCannotDisconnect: "La sesión local no se puede desconectar", + notADirectory: '"{path}" existe y no es una carpeta', + sameFileTransfer: '"{name}": el origen y el destino son el mismo archivo', + sameFileNoName: "El origen y el destino son el mismo archivo", + folderOntoItself: "No se puede copiar una carpeta sobre sí misma", + folderIntoItself: "No se puede copiar una carpeta dentro de sí misma", + }, +} as const; diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts new file mode 100644 index 0000000..73afa8d --- /dev/null +++ b/src/shared/i18n/eu.ts @@ -0,0 +1,301 @@ +// Basque (Euskara) translations. Fall back to English for missing keys. +// TODO(i18n): AI draft — REQUIRES native-speaker review (Basque is error-prone +// for machine/AI translation; treat every string as provisional). +export const eu = { + common: { + cancel: "Utzi", + save: "Gorde", + close: "Itxi", + delete: "Ezabatu", + rename: "Berrizendatu", + create: "Sortu", + apply: "Aplikatu", + choose: "Aukeratu…", + connect: "Konektatu", + local: "Lokala", + }, + app: { + notConnectedTitle: "Ez dago aplikaziora konektatuta", + notConnectedBody: "Orri hau nabigatzaile arrunt batean exekutatzen ari da. Ireki mahaigaineko aplikazioa erabiltzeko.", + managerOpen: "Konexio-kudeatzailea irekita dago.", + showConnections: "Erakutsi konexioak", + }, + tabBar: { + connections: "Konexioak", + manageConnections: "Kudeatu konexioak", + newTab: "Fitxa berria ({mod}T)", + closeTab: "Itxi fitxa ({mod}W)", + options: "Aukerak", + }, + options: { + title: "Aukerak", + language: "Hizkuntza", + system: "Sistema", + theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" }, + tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, + accent: { label: "Azentua" }, + accentNames: { + graphite: "Grafitoa", + blue: "Urdina", + indigo: "Indigoa", + purple: "Morea", + rose: "Arrosa", + orange: "Laranja", + green: "Berdea", + teal: "Turkesa", + }, + fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" }, + interface: { label: "Interfazea", compact: "Trinkoa", default: "Lehenetsia", large: "Handia" }, + speedUnit: { label: "Abiadura-unitatea" }, + transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" }, + atOnce: "Aldi berean", + editor: { label: "Editorea", builtin: "Barnekoa", custom: "Pertsonalizatua" }, + systemDefault: "Sistemaren lehenetsia", + resetDefault: "Berrezarri lehenetsira", + finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" }, + }, + kinds: { + folder: "Karpeta", + symlink: "Esteka sinbolikoa", + file: "Fitxategia", + image: "Irudia", + audio: "Audioa", + video: "Bideoa", + archive: "Artxiboa", + code: "Kodea", + document: "Dokumentua", + }, + columns: { + name: "Izena", + size: "Tamaina", + mtime: "Aldatze-data", + kind: "Mota", + rights: "Baimenak", + owner: "Jabea", + group: "Taldea", + }, + panel: { + choosePanel: "Aukeratu panel honek zer erakusten duen", + copy: "Kopiatu", + copyToRight: "Kopiatu eskuineko panelera (F5)", + copyToLeft: "Kopiatu ezkerreko panelera (F5)", + edit: "Editatu", + editFile: "Editatu fitxategia (F4)", + rename: "Berrizendatu (F2)", + newFolder: "Karpeta berria (F7)", + newFile: "Fitxategi berria", + permissions: "Baimenak", + delete: "Ezabatu", + deleteHint: "Ezabatu (F8 / Del)", + parentDir: "Karpeta gurasoa (Atzera-tekla)", + refresh: "Freskatu", + clearFilter: "Garbitu iragazkia (Esc)", + sshConfig: "~/.ssh/config", + connectingTo: "{name}(e)ra konektatzen…", + emptyDir: "Karpeta hutsa", + noMatches: "Bat-etortzerik ez", + matches: { one: "bat-etortze {count} · Esc", other: "{count} bat-etortze · Esc" }, + }, + connMgr: { + title: "Konexioak", + importWinscp: "Inportatu WinSCPtik…", + importWinscpHint: "Inportatu saioak WinSCP.ini esportazio batetik", + newConnection: "Konexioa", + newConnectionHint: "Konexio berria", + newStack: "Taldea", + newStackHint: "Talde berria", + expand: "Zabaldu", + collapse: "Tolestu", + renameStack: "Berrizendatu taldea", + deleteStack: "Ezabatu taldea", + openStack: "Ireki taldea fitxetan", + empty: "hutsik", + connectionsHeader: "Konexioak", + noneTitle: "Oraindik ez dago gordetako konexiorik.", + noneBody: "Sortu bat edo inportatu zure WinSCP saioak.", + name: "Izena", + namePlaceholder: "nire zerbitzaria", + host: "Ostalaria", + port: "Ataka", + stack: "Taldea", + stackNone: "(bat ere ez)", + username: "Erabiltzailea", + remotePath: "Urruneko bidea (aukerakoa)", + remotePathPlaceholder: "hasierako karpeta", + auth: "Autentifikazioa", + authPassword: "Pasahitza", + authKey: "Gako pribatua", + password: "Pasahitza", + privateKeyFile: "Gako pribatuaren fitxategia", + passphrase: "Pasaesaldia (aukerakoa)", + localDir: "Karpeta lokala (aukerakoa)", + localDirPlaceholder: "~/proiektuak/nire-zerbitzaria", + localDirHint: "Beste panela hemen irekiko da Lokalean dagoenean eta nabigatu ez denean.", + more: "Gehiago…", + less: "Gutxiago…", + selectPrompt: "Hautatu konexio bat edo sortu berri bat.", + newStackTitle: "Talde berria", + newStackPrompt: "taldearen izena", + renameStackTitle: "Berrizendatu taldea", + deleteStackTitle: "Ezabatu taldea", + deleteStackBody: 'Ezabatu "{name}" taldea? Bere konexioak mantendu egingo dira, taldekatu gabe.', + deleteConnectionTitle: "Ezabatu konexioa", + deleteConnectionBody: 'Ezabatu "{name}"?', + }, + editor: { + noFileTitle: "Ez dago editatzeko fitxategirik", + noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.", + wrap: "Doitu", + wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)", + findHint: "Bilatu ({mod}F)", + findPlaceholder: "Bilatu fitxategian", + prevMatch: "Aurreko bat-etortzea (⇧↵)", + nextMatch: "Hurrengo bat-etortzea (↵)", + closeHint: "Itxi (Esc)", + disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.", + cantOpen: "Ezin da {name} ireki", + closeWindow: "Itxi leihoa", + shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi", + lines: { one: "lerro {count}", other: "{count} lerro" }, + close: "Itxi", + save: "Gorde", + saveChangesTitle: 'Gorde "{name}"(r)en aldaketak?', + saveChangesBody: "Zure aldaketak galduko dira gordetzen ez badituzu.", + dontSave: "Ez gorde", + countLabel: "{total}(e)tik {current}", + noMatches: "Bat-etortzerik ez", + }, + transfer: { + summary: "{active} aktibo · {finished} amaituta", + clearFinished: "Garbitu amaituak", + cancel: "Utzi", + dismiss: "Baztertu", + showDetails: "Erakutsi transferentzien xehetasunak", + copying: "{name} kopiatzen · %{percent}{speed}", + queued: "{count} ilaran", + failed: { one: "transferentzia {count}(e)k huts egin du", other: "{count} transferentziak huts egin dute" }, + finished: "Transferentziak amaituta", + ready: "Prest", + items: "{count} elementu", + selected: "{count} hautatuta", + queuedState: "Ilaran", + scanning: "Aztertzen…", + failedState: "Huts egin du", + cancelled: "Bertan behera utzita", + completedFiles: { one: "fitxategi {count} · {size}", other: "{count} fitxategi · {size}" }, + left: "{eta} geratzen da", + }, + dialogs: { + chmodTitle: "Baimenak", + chmodItems: "{count} elementu", + read: "Irakurri", + write: "Idatzi", + execute: "Exekutatu", + roleOwner: "Jabea", + roleGroup: "Taldea", + roleOthers: "Besteak", + octal: "Zortzitarra", + mergeFolder: "Batu karpeta", + replace: "Ordeztu", + merge: "Batu", + mergeAll: "Batu guztiak", + replaceAll: "Ordeztu guztiak", + skip: "Saltatu", + skipAll: "Saltatu guztiak", + ofTotal: "{total}(e)tik {index}", + overwriteDirBody: "Helmugan izen bereko karpeta bat dago jada. Batzeak barneko fitxategiak ordeztu ditzake.", + overwriteFileBody: "Helmugan izen bereko fitxategi bat dago jada.", + source: "Iturburua", + destination: "Helmuga", + newer: "berriagoa", + }, + toasts: { + movedToTrash: '"{name}" zakarrontzira eraman da', + editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da", + nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)", + nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira", + sameFolder: "Bi panelek karpeta bera erakusten dute", + sameFile: '"{name}" saltatu da — fitxategi bera da', + typeMismatch: '"{name}" saltatu da — beste mota bateko elementu bat dago bidean', + transferFailed: '"{name}" transferentziak huts egin du: {error}', + unknownError: "errore ezezaguna", + saved: "{name} gorde da", + disconnectedReason: "{title}: deskonektatuta ({reason})", + imported: { one: "konexio {count} inportatu da", other: "{count} konexio inportatu dira" }, + importedIntoStacks: { one: " talde berri {count}ean", other: " {count} talde berritan" }, + importedSkipped: " ({skipped} saltatuta)", + leftoverTitle: "Geratutako editatutako fitxategiak", + leftoverMore: ", +{count} gehiago", + leftoverBody: { + one: "Aurreko saio batek editatutako aldi baterako fitxategi {count} utzi zuen ({names}). Garbitu?", + other: "Aurreko saio batek editatutako aldi baterako {count} fitxategi utzi zituen ({names}). Garbitu?", + }, + leftoverClear: "Garbitu", + }, + fileActions: { + deleteTitle: "Ezabatu", + deleteBody: "Ezabatu {what}? Karpetak errekurtsiboki ezabatzen dira. Ezin da desegin.", + deleteConfirm: "Ezabatu", + renameTitle: 'Berrizendatu "{name}"', + renameConfirm: "Berrizendatu", + newFolderTitle: "Karpeta berria", + newFolderPlaceholder: "karpetaren izena", + newFileTitle: "Fitxategi berria", + newFilePlaceholder: "fitxategiaren izena", + createConfirm: "Sortu", + itemsCount: "{count} elementu", + }, + menu: { + about: "{app} (r)i buruz", + quit: "Irten", + file: "Fitxategia", + newTab: "Fitxa berria", + closeTab: "Itxi fitxa", + newFolder: "Karpeta berria", + newFile: "Fitxategi berria", + editFile: "Editatu fitxategia", + connect: "Konektatu", + connectionManager: "Konexio-kudeatzailea…", + edit: "Editatu", + view: "Ikusi", + refresh: "Freskatu", + window: "Leihoa", + }, + ctx: { + size: "Tamaina", + modified: "Aldatze-data", + kind: "Mota", + rights: "Baimenak", + owner: "Jabea", + group: "Taldea", + revealFinder: "Erakutsi Finder-en", + revealExplorer: "Erakutsi Explorer-en", + revealFileManager: "Erakutsi fitxategi-kudeatzailean", + moveToTrash: "Eraman zakarrontzira", + moveToRecycleBin: "Eraman birziklatze-ontzira", + newFolder: "Karpeta berria", + newFile: "Fitxategi berria", + refresh: "Freskatu", + open: "Ireki", + openDefault: "Ireki lehenetsitako aplikazioarekin", + edit: "Editatu", + copyToOther: "Kopiatu beste panelera", + rename: "Berrizendatu", + permissions: "Baimenak", + copyPath: "Kopiatu bidea", + delete: "Ezabatu", + columns: "Zutabeak", + trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat", + }, + errors: { + sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)", + identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}", + sessionNotConnected: '"{id}" saioa ez dago konektatuta', + localCannotDisconnect: "Saio lokala ezin da deskonektatu", + notADirectory: '"{path}" badago eta ez da karpeta bat', + sameFileTransfer: '"{name}": iturburua eta helmuga fitxategi bera dira', + sameFileNoName: "Iturburua eta helmuga fitxategi bera dira", + folderOntoItself: "Ezin da karpeta bat bere gainean kopiatu", + folderIntoItself: "Ezin da karpeta bat bere barnean kopiatu", + }, +} as const; diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts new file mode 100644 index 0000000..bb2f00e --- /dev/null +++ b/src/shared/i18n/fr.ts @@ -0,0 +1,299 @@ +// French translations. Missing keys fall back to English (see en.ts / index.ts). +export const fr = { + common: { + cancel: "Annuler", + save: "Enregistrer", + close: "Fermer", + delete: "Supprimer", + rename: "Renommer", + create: "Créer", + apply: "Appliquer", + choose: "Choisir…", + connect: "Connecter", + local: "Local", + }, + app: { + notConnectedTitle: "Non connecté à l'application", + notConnectedBody: "Cette page s'exécute dans un navigateur classique. Lancez l'application de bureau pour l'utiliser.", + managerOpen: "Le gestionnaire de connexions est ouvert.", + showConnections: "Afficher les connexions", + }, + tabBar: { + connections: "Connexions", + manageConnections: "Gérer les connexions", + newTab: "Nouvel onglet ({mod}T)", + closeTab: "Fermer l'onglet ({mod}W)", + options: "Options", + }, + options: { + title: "Options", + language: "Langue", + system: "Système", + theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" }, + tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, + accent: { label: "Accent" }, + accentNames: { + graphite: "Graphite", + blue: "Bleu", + indigo: "Indigo", + purple: "Violet", + rose: "Rose", + orange: "Orange", + green: "Vert", + teal: "Sarcelle", + }, + fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" }, + interface: { label: "Interface", compact: "Compacte", default: "Normale", large: "Grande" }, + speedUnit: { label: "Unité de vitesse" }, + transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" }, + atOnce: "Simultanés", + editor: { label: "Éditeur", builtin: "Intégré", custom: "Personnalisé" }, + systemDefault: "Valeur par défaut du système", + resetDefault: "Réinitialiser par défaut", + finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" }, + }, + kinds: { + folder: "Dossier", + symlink: "Lien symbolique", + file: "Fichier", + image: "Image", + audio: "Audio", + video: "Vidéo", + archive: "Archive", + code: "Code", + document: "Document", + }, + columns: { + name: "Nom", + size: "Taille", + mtime: "Modifié", + kind: "Type", + rights: "Droits", + owner: "Propriétaire", + group: "Groupe", + }, + panel: { + choosePanel: "Choisir ce qu'affiche ce panneau", + copy: "Copier", + copyToRight: "Copier vers le panneau droit (F5)", + copyToLeft: "Copier vers le panneau gauche (F5)", + edit: "Éditer", + editFile: "Éditer le fichier (F4)", + rename: "Renommer (F2)", + newFolder: "Nouveau dossier (F7)", + newFile: "Nouveau fichier", + permissions: "Autorisations", + delete: "Supprimer", + deleteHint: "Supprimer (F8 / Suppr)", + parentDir: "Dossier parent (Retour arrière)", + refresh: "Actualiser", + clearFilter: "Effacer le filtre (Échap)", + sshConfig: "~/.ssh/config", + connectingTo: "Connexion à {name}…", + emptyDir: "Dossier vide", + noMatches: "Aucun résultat", + matches: { one: "{count} résultat · Échap", other: "{count} résultats · Échap" }, + }, + connMgr: { + title: "Connexions", + importWinscp: "Importer depuis WinSCP…", + importWinscpHint: "Importer des sessions depuis un export WinSCP.ini", + newConnection: "Connexion", + newConnectionHint: "Nouvelle connexion", + newStack: "Groupe", + newStackHint: "Nouveau groupe", + expand: "Développer", + collapse: "Réduire", + renameStack: "Renommer le groupe", + deleteStack: "Supprimer le groupe", + openStack: "Ouvrir le groupe dans des onglets", + empty: "vide", + connectionsHeader: "Connexions", + noneTitle: "Aucune connexion enregistrée.", + noneBody: "Créez-en une ou importez vos sessions WinSCP.", + name: "Nom", + namePlaceholder: "mon serveur", + host: "Hôte", + port: "Port", + stack: "Groupe", + stackNone: "(aucun)", + username: "Utilisateur", + remotePath: "Chemin distant (facultatif)", + remotePathPlaceholder: "dossier personnel", + auth: "Authentification", + authPassword: "Mot de passe", + authKey: "Clé privée", + password: "Mot de passe", + privateKeyFile: "Fichier de clé privée", + passphrase: "Phrase secrète (facultative)", + localDir: "Dossier local (facultatif)", + localDirPlaceholder: "~/projets/mon-serveur", + localDirHint: "L'autre panneau s'ouvre ici lorsqu'il est sur Local et n'a pas été parcouru.", + more: "Plus…", + less: "Moins…", + selectPrompt: "Sélectionnez une connexion ou créez-en une nouvelle.", + newStackTitle: "Nouveau groupe", + newStackPrompt: "nom du groupe", + renameStackTitle: "Renommer le groupe", + deleteStackTitle: "Supprimer le groupe", + deleteStackBody: 'Supprimer le groupe « {name} » ? Ses connexions sont conservées, sans groupe.', + deleteConnectionTitle: "Supprimer la connexion", + deleteConnectionBody: 'Supprimer « {name} » ?', + }, + editor: { + noFileTitle: "Aucun fichier à éditer", + noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.", + wrap: "Retour", + wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)", + findHint: "Rechercher ({mod}F)", + findPlaceholder: "Rechercher dans le fichier", + prevMatch: "Résultat précédent (⇧↵)", + nextMatch: "Résultat suivant (↵)", + closeHint: "Fermer (Échap)", + disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.", + cantOpen: "Impossible d'ouvrir {name}", + closeWindow: "Fermer la fenêtre", + shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer", + lines: { one: "{count} ligne", other: "{count} lignes" }, + close: "Fermer", + save: "Enregistrer", + saveChangesTitle: 'Enregistrer les modifications de « {name} » ?', + saveChangesBody: "Vos modifications seront perdues si vous ne les enregistrez pas.", + dontSave: "Ne pas enregistrer", + countLabel: "{current} sur {total}", + noMatches: "Aucun résultat", + }, + transfer: { + summary: "{active} en cours · {finished} terminés", + clearFinished: "Effacer les terminés", + cancel: "Annuler", + dismiss: "Ignorer", + showDetails: "Afficher les détails des transferts", + copying: "Copie de {name} · {percent}%{speed}", + queued: "{count} en file", + failed: { one: "{count} transfert échoué", other: "{count} transferts échoués" }, + finished: "Transferts terminés", + ready: "Prêt", + items: "{count} éléments", + selected: "{count} sélectionnés", + queuedState: "En file", + scanning: "Analyse…", + failedState: "Échec", + cancelled: "Annulé", + completedFiles: { one: "{count} fichier · {size}", other: "{count} fichiers · {size}" }, + left: "{eta} restant", + }, + dialogs: { + chmodTitle: "Autorisations", + chmodItems: "{count} éléments", + read: "Lecture", + write: "Écriture", + execute: "Exécution", + roleOwner: "Propriétaire", + roleGroup: "Groupe", + roleOthers: "Autres", + octal: "Octal", + mergeFolder: "Fusionner le dossier", + replace: "Remplacer", + merge: "Fusionner", + mergeAll: "Tout fusionner", + replaceAll: "Tout remplacer", + skip: "Ignorer", + skipAll: "Tout ignorer", + ofTotal: "{index} sur {total}", + overwriteDirBody: "La destination contient déjà un dossier de ce nom. La fusion peut remplacer des fichiers.", + overwriteFileBody: "La destination contient déjà un fichier de ce nom.", + source: "Source", + destination: "Destination", + newer: "plus récent", + }, + toasts: { + movedToTrash: '« {name} » mis à la corbeille', + editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement", + nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)", + nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés", + sameFolder: "Les deux panneaux affichent le même dossier", + sameFile: "« {name} » ignoré — c'est le même fichier", + typeMismatch: "« {name} » ignoré — un autre type d'élément est déjà là", + transferFailed: 'Le transfert « {name} » a échoué : {error}', + unknownError: "erreur inconnue", + saved: "{name} enregistré", + disconnectedReason: "{title} : déconnecté ({reason})", + imported: { one: "{count} connexion importée", other: "{count} connexions importées" }, + importedIntoStacks: { one: " dans {count} nouveau groupe", other: " dans {count} nouveaux groupes" }, + importedSkipped: " ({skipped} ignorées)", + leftoverTitle: "Fichiers édités restants", + leftoverMore: ", +{count} de plus", + leftoverBody: { + one: "Une session précédente a laissé {count} fichier édité temporaire ({names}). Les supprimer ?", + other: "Une session précédente a laissé {count} fichiers édités temporaires ({names}). Les supprimer ?", + }, + leftoverClear: "Supprimer", + }, + fileActions: { + deleteTitle: "Supprimer", + deleteBody: "Supprimer {what} ? Les dossiers sont supprimés récursivement. Action irréversible.", + deleteConfirm: "Supprimer", + renameTitle: 'Renommer « {name} »', + renameConfirm: "Renommer", + newFolderTitle: "Nouveau dossier", + newFolderPlaceholder: "nom du dossier", + newFileTitle: "Nouveau fichier", + newFilePlaceholder: "nom du fichier", + createConfirm: "Créer", + itemsCount: "{count} éléments", + }, + menu: { + about: "À propos de {app}", + quit: "Quitter", + file: "Fichier", + newTab: "Nouvel onglet", + closeTab: "Fermer l'onglet", + newFolder: "Nouveau dossier", + newFile: "Nouveau fichier", + editFile: "Éditer le fichier", + connect: "Connecter", + connectionManager: "Gestionnaire de connexions…", + edit: "Édition", + view: "Affichage", + refresh: "Actualiser", + window: "Fenêtre", + }, + ctx: { + size: "Taille", + modified: "Modifié", + kind: "Type", + rights: "Droits", + owner: "Propriétaire", + group: "Groupe", + revealFinder: "Afficher dans le Finder", + revealExplorer: "Afficher dans l'Explorateur", + revealFileManager: "Afficher dans le gestionnaire de fichiers", + moveToTrash: "Mettre à la corbeille", + moveToRecycleBin: "Mettre à la corbeille", + newFolder: "Nouveau dossier", + newFile: "Nouveau fichier", + refresh: "Actualiser", + open: "Ouvrir", + openDefault: "Ouvrir avec l'application par défaut", + edit: "Éditer", + copyToOther: "Copier vers l'autre panneau", + rename: "Renommer", + permissions: "Autorisations", + copyPath: "Copier le chemin", + delete: "Supprimer", + columns: "Colonnes", + trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici", + }, + errors: { + sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)", + identityFileNotFound: "fichier d'identité introuvable : {path}", + sessionNotConnected: "La session « {id} » n'est pas connectée", + localCannotDisconnect: "La session locale ne peut pas être déconnectée", + notADirectory: "« {path} » existe et n'est pas un dossier", + sameFileTransfer: '« {name} » : la source et la destination sont le même fichier', + sameFileNoName: "La source et la destination sont le même fichier", + folderOntoItself: "Impossible de copier un dossier sur lui-même", + folderIntoItself: "Impossible de copier un dossier dans lui-même", + }, +} as const; diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts new file mode 100644 index 0000000..abf1bb4 --- /dev/null +++ b/src/shared/i18n/index.ts @@ -0,0 +1,43 @@ +import type { SupportedLocale } from "../types"; +import { de } from "./de"; +import { en } from "./en"; +import { es } from "./es"; +import { eu } from "./eu"; +import { fr } from "./fr"; +import { ja } from "./ja"; +import { zh } from "./zh"; + +/** A translation tree: leaves are strings; a leaf may be a plural map. */ +export type Messages = { readonly [key: string]: string | Messages }; + +export const DICTS: Record = { en, es, eu, fr, de, zh, ja }; + +/** Order shown in the language picker. */ +export const SUPPORTED: readonly SupportedLocale[] = ["en", "es", "eu", "fr", "de", "zh", "ja"]; + +/** Each language named in itself (endonyms) for the picker. */ +export const LOCALE_LABELS: Record = { + en: "English", + es: "Español", + eu: "Euskara", + fr: "Français", + de: "Deutsch", + zh: "中文", + ja: "日本語", +}; + +/** + * Resolve the effective locale: an explicit choice wins; "system" maps the OS + * BCP-47 tag (e.g. "es-ES", "zh-Hans-CN") to a supported language, else English. + */ +export function resolveLocale( + setting: "system" | SupportedLocale, + osLocale: string | undefined, +): SupportedLocale { + if (setting !== "system") return setting; + const tag = (osLocale ?? "en").toLowerCase(); + const primary = tag.split("-")[0]; + return (SUPPORTED as readonly string[]).includes(primary) + ? (primary as SupportedLocale) + : "en"; +} diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts new file mode 100644 index 0000000..0af58af --- /dev/null +++ b/src/shared/i18n/ja.ts @@ -0,0 +1,299 @@ +// Japanese translations. Fall back to English for missing keys. +// TODO(i18n): AI draft — needs native-speaker review. +export const ja = { + common: { + cancel: "キャンセル", + save: "保存", + close: "閉じる", + delete: "削除", + rename: "名前を変更", + create: "作成", + apply: "適用", + choose: "選択…", + connect: "接続", + local: "ローカル", + }, + app: { + notConnectedTitle: "アプリに接続されていません", + notConnectedBody: "このページは通常のブラウザで動作しています。デスクトップアプリを起動して使用してください。", + managerOpen: "接続マネージャーが開いています。", + showConnections: "接続を表示", + }, + tabBar: { + connections: "接続", + manageConnections: "接続を管理", + newTab: "新しいタブ ({mod}T)", + closeTab: "タブを閉じる ({mod}W)", + options: "オプション", + }, + options: { + title: "オプション", + language: "言語", + system: "システム", + theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, + tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, + accent: { label: "アクセント" }, + accentNames: { + graphite: "グラファイト", + blue: "ブルー", + indigo: "インディゴ", + purple: "パープル", + rose: "ローズ", + orange: "オレンジ", + green: "グリーン", + teal: "ティール", + }, + fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" }, + interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" }, + speedUnit: { label: "速度の単位" }, + transfers: { label: "転送", single: "1件ずつ", multi: "複数" }, + atOnce: "同時数", + editor: { label: "エディタ", builtin: "内蔵", custom: "カスタム" }, + systemDefault: "システムの既定", + resetDefault: "既定に戻す", + finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" }, + }, + kinds: { + folder: "フォルダ", + symlink: "シンボリックリンク", + file: "ファイル", + image: "画像", + audio: "オーディオ", + video: "動画", + archive: "アーカイブ", + code: "コード", + document: "ドキュメント", + }, + columns: { + name: "名前", + size: "サイズ", + mtime: "更新日時", + kind: "種類", + rights: "権限", + owner: "所有者", + group: "グループ", + }, + panel: { + choosePanel: "このパネルに表示する内容を選択", + copy: "コピー", + copyToRight: "右のパネルへコピー (F5)", + copyToLeft: "左のパネルへコピー (F5)", + edit: "編集", + editFile: "ファイルを編集 (F4)", + rename: "名前を変更 (F2)", + newFolder: "新規フォルダ (F7)", + newFile: "新規ファイル", + permissions: "アクセス権", + delete: "削除", + deleteHint: "削除 (F8 / Del)", + parentDir: "親フォルダ (Backspace)", + refresh: "更新", + clearFilter: "フィルターをクリア (Esc)", + sshConfig: "~/.ssh/config", + connectingTo: "{name} に接続中…", + emptyDir: "空のフォルダ", + noMatches: "一致なし", + matches: { other: "{count} 件一致 · Esc" }, + }, + connMgr: { + title: "接続", + importWinscp: "WinSCP からインポート…", + importWinscpHint: "WinSCP.ini エクスポートからセッションをインポート", + newConnection: "接続", + newConnectionHint: "新規接続", + newStack: "グループ", + newStackHint: "新規グループ", + expand: "展開", + collapse: "折りたたむ", + renameStack: "グループ名を変更", + deleteStack: "グループを削除", + openStack: "グループをタブで開く", + empty: "空", + connectionsHeader: "接続", + noneTitle: "保存された接続はまだありません。", + noneBody: "作成するか、WinSCP セッションをインポートしてください。", + name: "名前", + namePlaceholder: "マイサーバー", + host: "ホスト", + port: "ポート", + stack: "グループ", + stackNone: "(なし)", + username: "ユーザー名", + remotePath: "リモートパス(任意)", + remotePathPlaceholder: "ホームディレクトリ", + auth: "認証", + authPassword: "パスワード", + authKey: "秘密鍵", + password: "パスワード", + privateKeyFile: "秘密鍵ファイル", + passphrase: "パスフレーズ(任意)", + localDir: "ローカルディレクトリ(任意)", + localDirPlaceholder: "~/projects/my-server", + localDirHint: "もう一方のパネルがローカルで未操作のとき、ここが開きます。", + more: "詳細…", + less: "閉じる…", + selectPrompt: "接続を選択するか、新しく作成してください。", + newStackTitle: "新規グループ", + newStackPrompt: "グループ名", + renameStackTitle: "グループ名を変更", + deleteStackTitle: "グループを削除", + deleteStackBody: 'グループ "{name}" を削除しますか?含まれる接続は残り、グループ解除されます。', + deleteConnectionTitle: "接続を削除", + deleteConnectionBody: '"{name}" を削除しますか?', + }, + editor: { + noFileTitle: "編集するファイルがありません", + noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。", + wrap: "折り返し", + wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)", + findHint: "検索 ({mod}F)", + findPlaceholder: "ファイル内を検索", + prevMatch: "前の一致 (⇧↵)", + nextMatch: "次の一致 (↵)", + closeHint: "閉じる (Esc)", + disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。", + cantOpen: "{name} を開けません", + closeWindow: "ウィンドウを閉じる", + shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる", + lines: { other: "{count} 行" }, + close: "閉じる", + save: "保存", + saveChangesTitle: '"{name}" への変更を保存しますか?', + saveChangesBody: "保存しないと変更は失われます。", + dontSave: "保存しない", + countLabel: "{total} 件中 {current} 件目", + noMatches: "一致なし", + }, + transfer: { + summary: "{active} 件実行中 · {finished} 件完了", + clearFinished: "完了分をクリア", + cancel: "キャンセル", + dismiss: "閉じる", + showDetails: "転送の詳細を表示", + copying: "{name} をコピー中 · {percent}%{speed}", + queued: "{count} 件待機中", + failed: { other: "{count} 件の転送が失敗" }, + finished: "転送が完了しました", + ready: "準備完了", + items: "{count} 項目", + selected: "{count} 件選択中", + queuedState: "待機中", + scanning: "スキャン中…", + failedState: "失敗", + cancelled: "キャンセル済み", + completedFiles: { other: "{count} 個のファイル · {size}" }, + left: "残り {eta}", + }, + dialogs: { + chmodTitle: "アクセス権", + chmodItems: "{count} 項目", + read: "読み取り", + write: "書き込み", + execute: "実行", + roleOwner: "所有者", + roleGroup: "グループ", + roleOthers: "その他", + octal: "8進数", + mergeFolder: "フォルダを結合", + replace: "置き換える", + merge: "結合", + mergeAll: "すべて結合", + replaceAll: "すべて置き換える", + skip: "スキップ", + skipAll: "すべてスキップ", + ofTotal: "{total} 件中 {index} 件目", + overwriteDirBody: "コピー先に同名のフォルダが既にあります。結合すると中のファイルが置き換わる可能性があります。", + overwriteFileBody: "コピー先に同名のファイルが既にあります。", + source: "コピー元", + destination: "コピー先", + newer: "新しい", + }, + toasts: { + movedToTrash: '"{name}" をゴミ箱に移動しました', + editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます", + nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)", + nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました", + sameFolder: "両方のパネルが同じフォルダを表示しています", + sameFile: '"{name}" をスキップしました — 同じファイルです', + typeMismatch: '"{name}" をスキップしました — 別の種類の項目があります', + transferFailed: '転送 "{name}" が失敗しました: {error}', + unknownError: "不明なエラー", + saved: "{name} を保存しました", + disconnectedReason: "{title}: 切断されました ({reason})", + imported: { other: "{count} 件の接続をインポートしました" }, + importedIntoStacks: { other: "({count} 件の新規グループに)" }, + importedSkipped: "({skipped} 件スキップ)", + leftoverTitle: "残った編集済みファイル", + leftoverMore: "、他 {count} 件", + leftoverBody: { + other: "前回のセッションで {count} 件の一時編集ファイルが残っています({names})。クリアしますか?", + }, + leftoverClear: "クリア", + }, + fileActions: { + deleteTitle: "削除", + deleteBody: "{what} を削除しますか?フォルダは再帰的に削除されます。この操作は取り消せません。", + deleteConfirm: "削除", + renameTitle: '"{name}" の名前を変更', + renameConfirm: "名前を変更", + newFolderTitle: "新規フォルダ", + newFolderPlaceholder: "フォルダ名", + newFileTitle: "新規ファイル", + newFilePlaceholder: "ファイル名", + createConfirm: "作成", + itemsCount: "{count} 項目", + }, + menu: { + about: "{app} について", + quit: "終了", + file: "ファイル", + newTab: "新しいタブ", + closeTab: "タブを閉じる", + newFolder: "新規フォルダ", + newFile: "新規ファイル", + editFile: "ファイルを編集", + connect: "接続", + connectionManager: "接続マネージャー…", + edit: "編集", + view: "表示", + refresh: "更新", + window: "ウィンドウ", + }, + ctx: { + size: "サイズ", + modified: "更新日時", + kind: "種類", + rights: "権限", + owner: "所有者", + group: "グループ", + revealFinder: "Finder で表示", + revealExplorer: "エクスプローラーで表示", + revealFileManager: "ファイルマネージャーで表示", + moveToTrash: "ゴミ箱に移動", + moveToRecycleBin: "ごみ箱に移動", + newFolder: "新規フォルダ", + newFile: "新規ファイル", + refresh: "更新", + open: "開く", + openDefault: "既定のアプリで開く", + edit: "編集", + copyToOther: "もう一方のパネルへコピー", + rename: "名前を変更", + permissions: "アクセス権", + copyPath: "パスをコピー", + delete: "削除", + columns: "列", + trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", + }, + errors: { + sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)", + identityFileNotFound: "認証鍵ファイルが見つかりません: {path}", + sessionNotConnected: 'セッション "{id}" は接続されていません', + localCannotDisconnect: "ローカルセッションは切断できません", + notADirectory: '"{path}" は存在し、フォルダではありません', + sameFileTransfer: '"{name}": コピー元とコピー先が同じファイルです', + sameFileNoName: "コピー元とコピー先が同じファイルです", + folderOntoItself: "フォルダをそれ自身の上にコピーすることはできません", + folderIntoItself: "フォルダをそれ自身の中にコピーすることはできません", + }, +} as const; diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts new file mode 100644 index 0000000..bdec8e1 --- /dev/null +++ b/src/shared/i18n/zh.ts @@ -0,0 +1,299 @@ +// Simplified Chinese translations. Fall back to English for missing keys. +// TODO(i18n): AI draft — needs native-speaker review. +export const zh = { + common: { + cancel: "取消", + save: "保存", + close: "关闭", + delete: "删除", + rename: "重命名", + create: "创建", + apply: "应用", + choose: "选择…", + connect: "连接", + local: "本地", + }, + app: { + notConnectedTitle: "未连接到应用", + notConnectedBody: "此页面运行在普通浏览器中。请启动桌面应用来使用。", + managerOpen: "连接管理器已打开。", + showConnections: "显示连接", + }, + tabBar: { + connections: "连接", + manageConnections: "管理连接", + newTab: "新建标签页 ({mod}T)", + closeTab: "关闭标签页 ({mod}W)", + options: "选项", + }, + options: { + title: "选项", + language: "语言", + system: "系统", + theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, + tone: { label: "色调", dark: "深色", darker: "更深" }, + accent: { label: "强调色" }, + accentNames: { + graphite: "石墨", + blue: "蓝色", + indigo: "靛蓝", + purple: "紫色", + rose: "玫红", + orange: "橙色", + green: "绿色", + teal: "青色", + }, + fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" }, + interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" }, + speedUnit: { label: "速度单位" }, + transfers: { label: "传输", single: "单个", multi: "多个" }, + atOnce: "同时", + editor: { label: "编辑器", builtin: "内置", custom: "自定义" }, + systemDefault: "系统默认", + resetDefault: "恢复为系统默认", + finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" }, + }, + kinds: { + folder: "文件夹", + symlink: "符号链接", + file: "文件", + image: "图像", + audio: "音频", + video: "视频", + archive: "压缩包", + code: "代码", + document: "文档", + }, + columns: { + name: "名称", + size: "大小", + mtime: "修改时间", + kind: "类型", + rights: "权限", + owner: "所有者", + group: "组", + }, + panel: { + choosePanel: "选择此面板显示的内容", + copy: "复制", + copyToRight: "复制到右侧面板 (F5)", + copyToLeft: "复制到左侧面板 (F5)", + edit: "编辑", + editFile: "编辑文件 (F4)", + rename: "重命名 (F2)", + newFolder: "新建文件夹 (F7)", + newFile: "新建文件", + permissions: "权限", + delete: "删除", + deleteHint: "删除 (F8 / Del)", + parentDir: "上级目录 (退格)", + refresh: "刷新", + clearFilter: "清除筛选 (Esc)", + sshConfig: "~/.ssh/config", + connectingTo: "正在连接 {name}…", + emptyDir: "空目录", + noMatches: "无匹配项", + matches: { other: "{count} 个匹配 · Esc" }, + }, + connMgr: { + title: "连接", + importWinscp: "从 WinSCP 导入…", + importWinscpHint: "从 WinSCP.ini 导出文件导入会话", + newConnection: "连接", + newConnectionHint: "新建连接", + newStack: "分组", + newStackHint: "新建分组", + expand: "展开", + collapse: "折叠", + renameStack: "重命名分组", + deleteStack: "删除分组", + openStack: "在标签页中打开分组", + empty: "空", + connectionsHeader: "连接", + noneTitle: "尚无已保存的连接。", + noneBody: "创建一个,或导入你的 WinSCP 会话。", + name: "名称", + namePlaceholder: "我的服务器", + host: "主机", + port: "端口", + stack: "分组", + stackNone: "(无)", + username: "用户名", + remotePath: "远程路径(可选)", + remotePathPlaceholder: "主目录", + auth: "认证", + authPassword: "密码", + authKey: "私钥", + password: "密码", + privateKeyFile: "私钥文件", + passphrase: "密钥口令(可选)", + localDir: "本地目录(可选)", + localDirPlaceholder: "~/projects/my-server", + localDirHint: "当另一个面板处于本地且未浏览过时,将在此打开。", + more: "更多…", + less: "收起…", + selectPrompt: "选择一个连接或新建一个。", + newStackTitle: "新建分组", + newStackPrompt: "分组名称", + renameStackTitle: "重命名分组", + deleteStackTitle: "删除分组", + deleteStackBody: '删除分组 "{name}"?其中的连接将保留但不再分组。', + deleteConnectionTitle: "删除连接", + deleteConnectionBody: '删除 "{name}"?', + }, + editor: { + noFileTitle: "没有要编辑的文件", + noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。", + wrap: "换行", + wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)", + findHint: "查找 ({mod}F)", + findPlaceholder: "在文件中查找", + prevMatch: "上一个匹配 (⇧↵)", + nextMatch: "下一个匹配 (↵)", + closeHint: "关闭 (Esc)", + disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。", + cantOpen: "无法打开 {name}", + closeWindow: "关闭窗口", + shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭", + lines: { other: "{count} 行" }, + close: "关闭", + save: "保存", + saveChangesTitle: '保存对 "{name}" 的更改?', + saveChangesBody: "如果不保存,你的更改将丢失。", + dontSave: "不保存", + countLabel: "第 {current} / {total}", + noMatches: "无匹配项", + }, + transfer: { + summary: "{active} 进行中 · {finished} 已完成", + clearFinished: "清除已完成", + cancel: "取消", + dismiss: "忽略", + showDetails: "显示传输详情", + copying: "正在复制 {name} · {percent}%{speed}", + queued: "{count} 个排队中", + failed: { other: "{count} 个传输失败" }, + finished: "传输已完成", + ready: "就绪", + items: "{count} 项", + selected: "已选 {count} 项", + queuedState: "排队中", + scanning: "扫描中…", + failedState: "失败", + cancelled: "已取消", + completedFiles: { other: "{count} 个文件 · {size}" }, + left: "剩余 {eta}", + }, + dialogs: { + chmodTitle: "权限", + chmodItems: "{count} 项", + read: "读", + write: "写", + execute: "执行", + roleOwner: "所有者", + roleGroup: "组", + roleOthers: "其他", + octal: "八进制", + mergeFolder: "合并文件夹", + replace: "替换", + merge: "合并", + mergeAll: "全部合并", + replaceAll: "全部替换", + skip: "跳过", + skipAll: "全部跳过", + ofTotal: "第 {index} / {total}", + overwriteDirBody: "目标位置已存在同名文件夹。合并可能会替换其中的文件。", + overwriteFileBody: "目标位置已存在同名文件。", + source: "源", + destination: "目标", + newer: "较新", + }, + toasts: { + movedToTrash: '已将 "{name}" 移到废纸篓', + editingExternal: "正在编辑 {name} — 保存后会自动上传", + nothingToCopy: "没有可复制的内容(符号链接将被跳过)", + nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过", + sameFolder: "两个面板显示的是同一个文件夹", + sameFile: '已跳过 "{name}" — 它是同一个文件', + typeMismatch: '已跳过 "{name}" — 目标处是不同类型的项目', + transferFailed: '传输 "{name}" 失败:{error}', + unknownError: "未知错误", + saved: "已保存 {name}", + disconnectedReason: "{title}:已断开({reason})", + imported: { other: "已导入 {count} 个连接" }, + importedIntoStacks: { other: ",归入 {count} 个新分组" }, + importedSkipped: "(跳过 {skipped} 个)", + leftoverTitle: "残留的已编辑文件", + leftoverMore: ",还有 {count} 个", + leftoverBody: { + other: "上一次会话遗留了 {count} 个临时编辑文件({names})。要清除吗?", + }, + leftoverClear: "清除", + }, + fileActions: { + deleteTitle: "删除", + deleteBody: "删除 {what}?文件夹将被递归删除。此操作无法撤销。", + deleteConfirm: "删除", + renameTitle: '重命名 "{name}"', + renameConfirm: "重命名", + newFolderTitle: "新建文件夹", + newFolderPlaceholder: "文件夹名称", + newFileTitle: "新建文件", + newFilePlaceholder: "文件名称", + createConfirm: "创建", + itemsCount: "{count} 项", + }, + menu: { + about: "关于 {app}", + quit: "退出", + file: "文件", + newTab: "新建标签页", + closeTab: "关闭标签页", + newFolder: "新建文件夹", + newFile: "新建文件", + editFile: "编辑文件", + connect: "连接", + connectionManager: "连接管理器…", + edit: "编辑", + view: "查看", + refresh: "刷新", + window: "窗口", + }, + ctx: { + size: "大小", + modified: "修改时间", + kind: "类型", + rights: "权限", + owner: "所有者", + group: "组", + revealFinder: "在访达中显示", + revealExplorer: "在资源管理器中显示", + revealFileManager: "在文件管理器中显示", + moveToTrash: "移到废纸篓", + moveToRecycleBin: "移到回收站", + newFolder: "新建文件夹", + newFile: "新建文件", + refresh: "刷新", + open: "打开", + openDefault: "用默认应用打开", + edit: "编辑", + copyToOther: "复制到另一个面板", + rename: "重命名", + permissions: "权限", + copyPath: "复制路径", + delete: "删除", + columns: "列", + trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", + }, + errors: { + sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)", + identityFileNotFound: "找不到身份文件:{path}", + sessionNotConnected: '会话 "{id}" 未连接', + localCannotDisconnect: "无法断开本地会话", + notADirectory: '"{path}" 已存在且不是文件夹', + sameFileTransfer: '"{name}":源和目标是同一个文件', + sameFileNoName: "源和目标是同一个文件", + folderOntoItself: "无法将文件夹复制到其自身之上", + folderIntoItself: "无法将文件夹复制到其自身内部", + }, +} as const; diff --git a/src/shared/types.ts b/src/shared/types.ts index 7595433..3fbb4a2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -62,6 +62,9 @@ export interface ConnectionsFile { connections: SavedConnection[]; } +/** UI languages the app ships translations for. */ +export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja"; + export type AccentTone = | "graphite" | "blue" @@ -73,6 +76,8 @@ export type AccentTone = | "teal"; export interface AppSettings { + /** UI language; "system" resolves to the OS locale (fallback English). */ + locale: "system" | SupportedLocale; theme: "system" | "light" | "dark"; /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ darkTone: "dark" | "darker"; @@ -102,6 +107,7 @@ export interface AppSettings { } export const DEFAULT_SETTINGS: AppSettings = { + locale: "system", theme: "system", darkTone: "dark", accent: "blue", diff --git a/ui/src/app/app.html b/ui/src/app/app.html index e3d8e2c..f90fe01 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -3,9 +3,9 @@
-

Not connected to the app

+

{{ i18n.t('app.notConnectedTitle') }}

- This page is running in a plain browser. Launch the desktop app to use it. + {{ i18n.t('app.notConnectedBody') }}

@@ -29,11 +29,11 @@ >
-

The connection manager is open.

+

{{ i18n.t('app.managerOpen') }}

diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 268e3b4..6fa6648 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -1,5 +1,6 @@ import { Component, inject, signal } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; +import { I18nService } from './i18n.service'; import { BRANDING } from '@shared/branding'; import type { ColumnKey } from '@shared/types'; import { ConnectionsService } from './connections.service'; @@ -42,6 +43,7 @@ import { TransfersService } from './transfers.service'; }, }) export class App { + readonly i18n = inject(I18nService); readonly rpc = inject(RpcService); readonly toast = inject(ToastService); readonly dialogs = inject(DialogsService); @@ -76,11 +78,12 @@ export class App { const res = await this.rpc.request.getEditLeftovers({}); if (!res.ok || !res.names.length) return; const shown = res.names.slice(0, 5).join(', '); - const more = res.names.length > 5 ? `, +${res.names.length - 5} more` : ''; + const more = + res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : ''; this.dialogs.confirm.set({ - title: 'Leftover edited files', - body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`, - confirmLabel: 'Clear', + title: this.i18n.t('toasts.leftoverTitle'), + body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }), + confirmLabel: this.i18n.t('toasts.leftoverClear'), destructive: true, onConfirm: () => void this.rpc.request.clearEditLeftovers({}), }); diff --git a/ui/src/app/connection-manager/connection-manager.html b/ui/src/app/connection-manager/connection-manager.html index 52e0b26..18f2964 100644 --- a/ui/src/app/connection-manager/connection-manager.html +++ b/ui/src/app/connection-manager/connection-manager.html @@ -10,12 +10,12 @@ [class.pl-3]="!isMacChrome" > -

Connections

+

{{ i18n.t('connMgr.title') }}

@@ -33,17 +33,17 @@
@@ -64,7 +64,7 @@ } @empty { -
empty
+
{{ i18n.t('connMgr.empty') }}
} } @@ -142,7 +142,7 @@ > @if (store.grouped().stacks.length && store.grouped().loose.length) {
- Connections + {{ i18n.t('connMgr.connectionsHeader') }}
} @for (connection of store.grouped().loose; track connection.id) { @@ -163,10 +163,10 @@ @if (!store.connections().length && !store.stacks().length) {

- No saved connections yet. + {{ i18n.t('connMgr.noneTitle') }}

- Create one, or import your WinSCP sessions. + {{ i18n.t('connMgr.noneBody') }}

} @@ -177,18 +177,18 @@ @if (draft(); as d) {
diff --git a/ui/src/app/connection-manager/connection-manager.ts b/ui/src/app/connection-manager/connection-manager.ts index 8e5423f..27ea3cb 100644 --- a/ui/src/app/connection-manager/connection-manager.ts +++ b/ui/src/app/connection-manager/connection-manager.ts @@ -1,5 +1,6 @@ import { Component, computed, inject, signal } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '../i18n.service'; import type { SavedConnection, Stack } from '@shared/types'; import { ConnectionsService } from '../connections.service'; import { ConfirmDialog } from '../dialogs/confirm-dialog'; @@ -79,6 +80,7 @@ function fromDraft(d: Draft): SavedConnection { }, }) export class ConnectionManager { + readonly i18n = inject(I18nService); readonly store = inject(ConnectionsService); readonly dialogs = inject(DialogsService); readonly isMacChrome = IS_MAC_CHROME; @@ -174,9 +176,9 @@ export class ConnectionManager { return; } this.dialogs.confirm.set({ - title: 'Delete connection', - body: `Delete "${d.name || d.host}"?`, - confirmLabel: 'Delete', + title: this.i18n.t('connMgr.deleteConnectionTitle'), + body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }), + confirmLabel: this.i18n.t('common.delete'), destructive: true, onConfirm: async () => { await this.store.remove(d.id); @@ -212,27 +214,27 @@ export class ConnectionManager { newStack(): void { this.dialogs.prompt.set({ - title: 'New stack', - placeholder: 'stack name', - confirmLabel: 'Create', + title: this.i18n.t('connMgr.newStackTitle'), + placeholder: this.i18n.t('connMgr.newStackPrompt'), + confirmLabel: this.i18n.t('fileActions.createConfirm'), onConfirm: (name) => void this.store.upsertStack({ id: '', name }), }); } renameStack(stack: Stack): void { this.dialogs.prompt.set({ - title: 'Rename stack', + title: this.i18n.t('connMgr.renameStackTitle'), initial: stack.name, - confirmLabel: 'Rename', + confirmLabel: this.i18n.t('connMgr.renameStack'), onConfirm: (name) => void this.store.upsertStack({ ...stack, name }), }); } deleteStack(stack: Stack): void { this.dialogs.confirm.set({ - title: 'Delete stack', - body: `Delete stack "${stack.name}"? Its connections stay, ungrouped.`, - confirmLabel: 'Delete', + title: this.i18n.t('connMgr.deleteStackTitle'), + body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }), + confirmLabel: this.i18n.t('common.delete'), destructive: true, onConfirm: () => void this.store.removeStack(stack.id), }); diff --git a/ui/src/app/connections.service.ts b/ui/src/app/connections.service.ts index f415691..df6701f 100644 --- a/ui/src/app/connections.service.ts +++ b/ui/src/app/connections.service.ts @@ -1,10 +1,12 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import type { SavedConnection, SshConfigHost, Stack } from '@shared/types'; import { RpcService } from './rpc.service'; +import { I18nService } from './i18n.service'; import { ToastService } from './toast.service'; @Injectable({ providedIn: 'root' }) export class ConnectionsService { + private readonly i18n = inject(I18nService); private readonly rpc = inject(RpcService); private readonly toast = inject(ToastService); @@ -86,11 +88,10 @@ export class ConnectionsService { return; } if (res.connections || res.stacks || res.skipped) { - const skipped = res.skipped ? ` (${res.skipped} skipped)` : ''; + const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : ''; + const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : ''; this.toast.success( - `Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` + - (res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') + - skipped, + this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped, ); } await this.reload(); diff --git a/ui/src/app/dialogs/chmod-dialog.ts b/ui/src/app/dialogs/chmod-dialog.ts index 2c1d0d1..ec0cb10 100644 --- a/ui/src/app/dialogs/chmod-dialog.ts +++ b/ui/src/app/dialogs/chmod-dialog.ts @@ -1,8 +1,9 @@ import { Component, computed, inject, input, signal } from '@angular/core'; +import { I18nService } from '../i18n.service'; import { DialogsService, type ChmodRequest } from '../dialogs.service'; import { FocusService } from '../focus.service'; -const ROLES = ['Owner', 'Group', 'Others'] as const; +const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const; const PERMS = ['r', 'w', 'x'] as const; @Component({ @@ -14,22 +15,22 @@ const PERMS = ['r', 'w', 'x'] as const; (mousedown)="$event.stopPropagation()" (keydown.escape)="cancel()" > -

Permissions

+

{{ i18n.t('dialogs.chmodTitle') }}

{{ targetLabel() }}

- - - + + + @for (role of roles; track role; let r = $index) { - + @for (perm of perms; track perm; let p = $index) {
ReadWriteExecute{{ i18n.t('dialogs.read') }}{{ i18n.t('dialogs.write') }}{{ i18n.t('dialogs.execute') }}
{{ role }}{{ i18n.t('dialogs.' + role) }}
- + - Cancel + {{ i18n.t('common.cancel') }}
@@ -75,6 +76,7 @@ const PERMS = ['r', 'w', 'x'] as const; `, }) export class ChmodDialog { + readonly i18n = inject(I18nService); readonly request = input.required(); readonly roles = ROLES; @@ -90,7 +92,9 @@ export class ChmodDialog { targetLabel(): string { const targets = this.request().targets; - return targets.length === 1 ? targets[0] : `${targets.length} items`; + return targets.length === 1 + ? targets[0] + : this.i18n.t('dialogs.chmodItems', { count: targets.length }); } hasBit(role: number, perm: number): boolean { diff --git a/ui/src/app/dialogs/confirm-dialog.ts b/ui/src/app/dialogs/confirm-dialog.ts index 3fb260c..820df95 100644 --- a/ui/src/app/dialogs/confirm-dialog.ts +++ b/ui/src/app/dialogs/confirm-dialog.ts @@ -1,4 +1,5 @@ import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; +import { I18nService } from '../i18n.service'; import { DialogsService, type ConfirmRequest } from '../dialogs.service'; import { FocusService } from '../focus.service'; @@ -18,7 +19,7 @@ import { FocusService } from '../focus.service'; (click)="cancel()" class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" > - Cancel + {{ i18n.t('common.cancel') }}
@if (request().total > 1) { @@ -90,13 +89,13 @@ import { FocusService } from '../focus.service'; (click)="pick('skipAll')" class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" > - Skip all + {{ i18n.t('dialogs.skipAll') }}
} @@ -104,14 +103,14 @@ import { FocusService } from '../focus.service'; (click)="pick('skip')" class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" > - Skip + {{ i18n.t('dialogs.skip') }} @@ -119,10 +118,9 @@ import { FocusService } from '../focus.service'; `, }) export class OverwriteDialog { + readonly i18n = inject(I18nService); readonly request = input.required(); - readonly formatSize = formatSize; - readonly formatDate = formatDate; private readonly dialogs = inject(DialogsService); private readonly focus = inject(FocusService); @@ -137,8 +135,8 @@ export class OverwriteDialog { readonly comparison = computed(() => { const { srcEntry, dstEntry } = this.request(); return [ - { label: 'Source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime }, - { label: 'Destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime }, + { label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime }, + { label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime }, ]; }); diff --git a/ui/src/app/dialogs/prompt-dialog.ts b/ui/src/app/dialogs/prompt-dialog.ts index 89dd709..72bed7c 100644 --- a/ui/src/app/dialogs/prompt-dialog.ts +++ b/ui/src/app/dialogs/prompt-dialog.ts @@ -7,6 +7,7 @@ import { signal, viewChild, } from '@angular/core'; +import { I18nService } from '../i18n.service'; import { DialogsService, type PromptRequest } from '../dialogs.service'; import { FocusService } from '../focus.service'; @@ -34,7 +35,7 @@ import { FocusService } from '../focus.service'; (click)="cancel()" class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" > - Cancel + {{ i18n.t('common.cancel') }} @@ -142,17 +141,17 @@
- {{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close + {{ i18n.t('editor.shortcuts', { mod: modLabel }) }} · - {{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }} + {{ i18n.t('editor.lines', { count: lineCount() }) }}
} @@ -175,30 +174,30 @@ class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" (mousedown)="$event.stopPropagation()" > -

Save changes to "{{ ctx.name }}"?

+

{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}

- Your changes will be lost if you don't save them. + {{ i18n.t('editor.saveChangesBody') }}

diff --git a/ui/src/app/editor-window/editor-window.ts b/ui/src/app/editor-window/editor-window.ts index 8eb0d46..2c5a19c 100644 --- a/ui/src/app/editor-window/editor-window.ts +++ b/ui/src/app/editor-window/editor-window.ts @@ -10,6 +10,7 @@ import { viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '../i18n.service'; import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform'; import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; @@ -38,6 +39,7 @@ const DRAFT_DEBOUNCE_MS = 400; }, }) export class EditorWindow { + readonly i18n = inject(I18nService); readonly ctx = EDITOR_CONTEXT; readonly isMacChrome = IS_MAC_CHROME; readonly modLabel = MOD_LABEL; @@ -273,7 +275,7 @@ export class EditorWindow { } this.savedContent.set(content); this.lastSentDirty = false; // bun record already cleaned by the handler - this.toast.success(`Saved ${this.ctx!.name}`); + this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name })); return true; } finally { this.saving.set(false); diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts index dd709b9..48d9375 100644 --- a/ui/src/app/file-actions.service.ts +++ b/ui/src/app/file-actions.service.ts @@ -6,6 +6,7 @@ import { type PanelState, type Side } from './panels.service'; import { IS_WINDOWS } from './platform'; import { RpcService } from './rpc.service'; import { SettingsService } from './settings.service'; +import { I18nService } from './i18n.service'; import { ToastService } from './toast.service'; /** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */ @@ -21,6 +22,7 @@ export interface MenuTarget { */ @Injectable({ providedIn: 'root' }) export class FileActionsService { + private readonly i18n = inject(I18nService); private readonly rpc = inject(RpcService); private readonly toast = inject(ToastService); private readonly dialogs = inject(DialogsService); @@ -44,7 +46,7 @@ export class FileActionsService { async trash(panel: PanelState, entry: FileEntry): Promise { const res = await this.rpc.request.moveToTrash({ path: entry.path }); if (!res.ok) this.toast.error(res.error); - else this.toast.info(`Moved "${entry.name}" to Trash`); + else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name })); await panel.refresh(); } @@ -58,9 +60,9 @@ export class FileActionsService { mkdir(panel: PanelState): void { this.dialogs.prompt.set({ - title: 'New folder', - placeholder: 'folder name', - confirmLabel: 'Create', + title: this.i18n.t('fileActions.newFolderTitle'), + placeholder: this.i18n.t('fileActions.newFolderPlaceholder'), + confirmLabel: this.i18n.t('fileActions.createConfirm'), onConfirm: async (name) => { const res = await this.rpc.request.mkdir({ sessionId: panel.sessionId(), @@ -74,9 +76,9 @@ export class FileActionsService { createFile(panel: PanelState): void { this.dialogs.prompt.set({ - title: 'New file', - placeholder: 'file name', - confirmLabel: 'Create', + title: this.i18n.t('fileActions.newFileTitle'), + placeholder: this.i18n.t('fileActions.newFilePlaceholder'), + confirmLabel: this.i18n.t('fileActions.createConfirm'), onConfirm: async (name) => { const res = await this.rpc.request.createFile({ sessionId: panel.sessionId(), @@ -101,7 +103,7 @@ export class FileActionsService { if (!res.ok) { this.toast.error(res.error); } else if (res.watching) { - this.toast.info(`Editing ${entry.name} — saves upload automatically`); + this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name })); } return; } @@ -120,9 +122,9 @@ export class FileActionsService { const entry = panel.sorted()[panel.cursor()]; if (!entry || entry.name === '..') return; this.dialogs.prompt.set({ - title: `Rename "${entry.name}"`, + title: this.i18n.t('fileActions.renameTitle', { name: entry.name }), initial: entry.name, - confirmLabel: 'Rename', + confirmLabel: this.i18n.t('fileActions.renameConfirm'), onConfirm: async (name) => { if (name === entry.name) return; const res = await this.rpc.request.rename({ @@ -140,11 +142,13 @@ export class FileActionsService { const targets = this.targets(panel); if (!targets.length) return; const what = - targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`; + targets.length === 1 + ? `"${targets[0].name}"` + : this.i18n.t('fileActions.itemsCount', { count: targets.length }); this.dialogs.confirm.set({ - title: 'Delete', - body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`, - confirmLabel: 'Delete', + title: this.i18n.t('fileActions.deleteTitle'), + body: this.i18n.t('fileActions.deleteBody', { what }), + confirmLabel: this.i18n.t('fileActions.deleteConfirm'), destructive: true, onConfirm: async () => { const res = await this.rpc.request.deleteEntries({ diff --git a/ui/src/app/file-icons.ts b/ui/src/app/file-icons.ts index 8b8e7b9..e5605ed 100644 --- a/ui/src/app/file-icons.ts +++ b/ui/src/app/file-icons.ts @@ -64,22 +64,25 @@ function categoryOf(name: string): Category | null { return EXT_CATEGORY.get(key) ?? null; } -const KIND_LABELS: Record = { - image: 'Image', - audio: 'Audio', - video: 'Video', - archive: 'Archive', - code: 'Code', - doc: 'Document', +const KIND_KEYS: Record = { + image: 'image', + audio: 'audio', + video: 'video', + archive: 'archive', + code: 'code', + doc: 'document', }; -/** Human label for the optional Kind column (category-based, like the icons). */ -export function fileKind(entry: FileEntry): string { +/** + * i18n key suffix for the optional Kind column (category-based, like the + * icons); '' for the synthetic '..' row. The caller resolves `kinds.`. + */ +export function fileKindKey(entry: FileEntry): string { if (entry.name === '..') return ''; - if (entry.type === 'dir') return 'Folder'; - if (entry.type === 'symlink') return 'Symlink'; + if (entry.type === 'dir') return 'folder'; + if (entry.type === 'symlink') return 'symlink'; const category = categoryOf(entry.name); - return category ? KIND_LABELS[category] : 'File'; + return category ? KIND_KEYS[category] : 'file'; } export function fileGlyph(entry: FileEntry, multi: boolean): string { diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index c5405ca..33a4c9b 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -3,7 +3,7 @@ @if (menuOpen()) { @@ -91,7 +91,7 @@ class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent" > - Local + {{ i18n.t('common.local') }} @if (!isRemote()) { } @@ -162,7 +162,7 @@ @for (col of visibleColumns(); track col.key) { } @@ -218,11 +218,11 @@ {{ query }} - {{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc + {{ i18n.t('panel.matches', { count: filterMatches() }) }} @@ -290,6 +290,8 @@ {{ fileKind(entry) }} + + } @case ('rights') { @@ -310,10 +312,10 @@ } } @empty { -
Empty directory
+
{{ i18n.t('panel.emptyDir') }}
} @if (panel().filter() && filterMatches() === 0) { -
No matches
+
{{ i18n.t('panel.noMatches') }}
} } diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 6294e5b..4881b7e 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -9,12 +9,13 @@ import { viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '../i18n.service'; import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import { ConnectionsService } from '../connections.service'; import { DialogsService } from '../dialogs.service'; import { FileActionsService } from '../file-actions.service'; -import { fileGlyph, fileIconClass, fileKind } from '../file-icons'; -import { formatDate, formatMode, formatSize } from '../format'; +import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons'; +import { formatMode } from '../format'; import { PanelsService, type Side, type SortKey } from '../panels.service'; import { IS_WINDOWS } from '../platform'; import { RpcService } from '../rpc.service'; @@ -38,6 +39,7 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [ host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' }, }) export class FilePanel { + readonly i18n = inject(I18nService); readonly side = input.required(); readonly panels = inject(PanelsService); @@ -65,10 +67,15 @@ export class FilePanel { private readonly listEl = viewChild.required>('list'); private lastClickedIndex = 0; - readonly formatSize = formatSize; - readonly formatDate = formatDate; + // locale-reactive wrappers: reading i18n.locale() inside makes the file-list + // cells re-render on a language switch (see i18n.service reactive t()) + readonly formatSize = (bytes: number): string => this.i18n.size(bytes); + readonly formatDate = (ms: number): string => this.i18n.date(ms); readonly formatMode = formatMode; - readonly fileKind = fileKind; + readonly fileKind = (entry: FileEntry): string => { + const key = fileKindKey(entry); + return key ? this.i18n.t('kinds.' + key) : ''; + }; /** The optional columns currently shown, in canonical order. */ readonly visibleColumns = computed(() => { diff --git a/ui/src/app/format.ts b/ui/src/app/format.ts index e7c287e..858b925 100644 --- a/ui/src/app/format.ts +++ b/ui/src/app/format.ts @@ -1,34 +1,51 @@ const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; -export function formatSize(bytes: number): string { +/** Locale-aware number formatting (decimal separator); cached per locale. */ +const NUM_FORMATS = new Map(); +function num(value: number, locale: string | undefined): string { + const key = locale ?? ''; + let fmt = NUM_FORMATS.get(key); + if (!fmt) { + fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + NUM_FORMATS.set(key, fmt); + } + return fmt.format(value); +} + +export function formatSize(bytes: number, locale?: string): string { let value = bytes; let unit = 0; while (value >= 1024 && unit < SIZE_UNITS.length - 1) { value /= 1024; unit++; } - return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`; + return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`; } -const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { - year: '2-digit', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', -}); - -export function formatDate(epochMs: number): string { +const DATE_FORMATS = new Map(); +export function formatDate(epochMs: number, locale?: string): string { if (!epochMs) return ''; - return DATE_FORMAT.format(new Date(epochMs)); + const key = locale ?? ''; + let fmt = DATE_FORMATS.get(key); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + DATE_FORMATS.set(key, fmt); + } + return fmt.format(new Date(epochMs)); } /** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */ -export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string { +export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string { if (unit === 'Mbps') { - return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`; + return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`; } - return `${formatSize(bytesPerSec)}/s`; + return `${formatSize(bytesPerSec, locale)}/s`; } /** Seconds -> "0:42" / "3:07" / "1:02:15". */ diff --git a/ui/src/app/i18n.service.ts b/ui/src/app/i18n.service.ts new file mode 100644 index 0000000..4f825d3 --- /dev/null +++ b/ui/src/app/i18n.service.ts @@ -0,0 +1,61 @@ +import { Injectable, computed, signal } from '@angular/core'; +import { DICTS, type Messages } from '@shared/i18n'; +import type { AppSettings, SupportedLocale } from '@shared/types'; +import { formatDate, formatSize, formatSpeed } from './format'; + +type Params = Record; + +function lookup(dict: Messages, key: string): string | Messages | undefined { + let node: string | Messages | undefined = dict; + for (const part of key.split('.')) { + if (typeof node !== 'object') return undefined; + node = node[part]; + } + return node; +} + +function interpolate(template: string, params?: Params): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_, name: string) => + name in params ? String(params[name]) : `{${name}}`, + ); +} + +/** + * Runtime translator. `t()` reads the `locale` signal, so any template binding + * that calls it re-renders live on a language switch (the same reactive + * function-call tracking the file-list icon classes rely on). Missing keys fall + * back to English, then to the raw key so gaps are visible in dev. + */ +@Injectable({ providedIn: 'root' }) +export class I18nService { + readonly locale = signal('en'); + private readonly dict = computed(() => DICTS[this.locale()]); + + t(key: string, params?: Params): string { + let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key); + // plural map: pick the CLDR category for params.count (fallback to English) + if (value && typeof value === 'object') { + const count = Number(params?.['count'] ?? NaN); + if (Number.isFinite(count)) { + const rule = new Intl.PluralRules(this.locale()).select(count); + const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`); + value = typeof picked === 'string' ? picked : undefined; + } else { + value = undefined; + } + } + return typeof value === 'string' ? interpolate(value, params) : key; + } + + // Locale-reactive formatting (live-updates the file list on switch). + date(epochMs: number): string { + return formatDate(epochMs, this.locale()); + } + size(bytes: number): string { + return formatSize(bytes, this.locale()); + } + speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string { + return formatSpeed(bytesPerSec, unit, this.locale()); + } +} diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index a8f3429..907e8cd 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -1,9 +1,10 @@ import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core'; import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; -import { fileKind } from './file-icons'; +import { fileKindKey } from './file-icons'; import { FocusService } from './focus.service'; import { formatMode, isRootPath, joinPath } from './format'; import { RpcService } from './rpc.service'; +import { I18nService } from './i18n.service'; import { ToastService } from './toast.service'; export type Side = 'left' | 'right'; @@ -12,7 +13,7 @@ export type SortKey = 'name' | ColumnKey; /** Display string the text-based columns sort by. */ function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string { - if (key === 'kind') return fileKind(entry); + if (key === 'kind') return fileKindKey(entry); if (key === 'rights') return formatMode(entry.mode); return (key === 'owner' ? entry.owner : entry.group) ?? ''; } @@ -225,6 +226,7 @@ export interface Workspace { @Injectable({ providedIn: 'root' }) export class PanelsService { + private readonly i18n = inject(I18nService); readonly workspaces = signal([]); readonly activeWorkspaceId = signal(0); readonly activeWorkspace = computed(() => { @@ -248,7 +250,12 @@ export class PanelsService { for (const workspace of this.workspaces()) { for (const panel of [workspace.left, workspace.right]) { if (panel.sessionId() === event.sessionId) { - this.toast.error(`${panel.title()}: disconnected (${event.reason})`); + this.toast.error( + this.i18n.t('toasts.disconnectedReason', { + title: panel.title(), + reason: event.reason, + }), + ); this.resetToLocal(panel); } } @@ -453,7 +460,7 @@ export class PanelsService { const remoteTitles = [workspace.left, workspace.right] .filter((p) => p.sessionId() !== 'local') .map((p) => p.title()); - return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local'; + return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local'); } workspaceIsRemote(workspace: Workspace): boolean { diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index eb5507e..e33bdf7 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -1,9 +1,12 @@ import { Injectable, computed, inject, signal } from '@angular/core'; +import { resolveLocale } from '@shared/i18n'; import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types'; +import { I18nService } from './i18n.service'; import { RpcService } from './rpc.service'; @Injectable({ providedIn: 'root' }) export class SettingsService { + readonly locale = signal(DEFAULT_SETTINGS.locale); readonly theme = signal(DEFAULT_SETTINGS.theme); readonly darkTone = signal(DEFAULT_SETTINGS.darkTone); readonly accent = signal(DEFAULT_SETTINGS.accent); @@ -23,6 +26,7 @@ export class SettingsService { // gate the poll/watch in panels.service on it, and add the tab-bar row below. private readonly rpc = inject(RpcService); + private readonly i18n = inject(I18nService); /** Whether the app is actually rendering dark right now (drives the Tone row). */ readonly effectiveDark = computed( @@ -45,6 +49,7 @@ export class SettingsService { } private applyAll(settings: AppSettings): void { + this.locale.set(settings.locale); this.theme.set(settings.theme); this.darkTone.set(settings.darkTone); this.accent.set(settings.accent); @@ -61,6 +66,13 @@ export class SettingsService { this.applyDarkTone(); this.applyAccent(); this.applyTextSize(); + this.applyLocale(); + } + + setLocale(locale: AppSettings['locale']): void { + this.locale.set(locale); + this.applyLocale(); + void this.persist(); } setTheme(theme: AppSettings['theme']): void { @@ -166,8 +178,13 @@ export class SettingsService { } } + private applyLocale(): void { + this.i18n.locale.set(resolveLocale(this.locale(), navigator.language)); + } + protected settings(): AppSettings { return { + locale: this.locale(), theme: this.theme(), darkTone: this.darkTone(), accent: this.accent(), diff --git a/ui/src/app/status-bar/status-bar.ts b/ui/src/app/status-bar/status-bar.ts index 5e8f8fe..a839387 100644 --- a/ui/src/app/status-bar/status-bar.ts +++ b/ui/src/app/status-bar/status-bar.ts @@ -1,5 +1,6 @@ import { Component, computed, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; +import { I18nService } from '../i18n.service'; import type { TransferSnapshot } from '@shared/types'; import { formatSpeed } from '../format'; import { PanelsService } from '../panels.service'; @@ -17,26 +18,26 @@ import { TransfersService } from '../transfers.service'; @@ -47,16 +48,19 @@ import { TransfersService } from '../transfers.service'; size="0.8rem" [class.text-primary]="activePanel().sessionId() !== 'local'" /> - {{ activePanel().title() }} - · {{ activePanel().sorted().length }} items + + {{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }} + + · {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }} @if (activePanel().selected().size; as count) { - · {{ count }} selected + · {{ i18n.t('transfer.selected', { count }) }} } `, }) export class StatusBar { + readonly i18n = inject(I18nService); readonly transfers = inject(TransfersService); readonly panels = inject(PanelsService); readonly settings = inject(SettingsService); diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index d4279db..e43a6bb 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -1,7 +1,9 @@ import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; +import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; import type { AccentTone } from '@shared/types'; +import { I18nService } from '../i18n.service'; import { PanelsService } from '../panels.service'; import { IS_MAC_CHROME, MOD_LABEL } from '../platform'; import { RpcService } from '../rpc.service'; @@ -10,16 +12,17 @@ import { SettingsService } from '../settings.service'; const THEMES = ['system', 'light', 'dark'] as const; const DARK_TONES = ['dark', 'darker'] as const; // Swatch colors are fixed mid-tones so every dot reads on both light and dark -// cards (the live --primary values would vanish against one of them). -const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [ - { value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)', label: 'Graphite' }, - { value: 'blue', swatch: 'oklch(0.65 0.17 255)', label: 'Blue' }, - { value: 'indigo', swatch: 'oklch(0.65 0.19 277)', label: 'Indigo' }, - { value: 'purple', swatch: 'oklch(0.65 0.17 305)', label: 'Purple' }, - { value: 'rose', swatch: 'oklch(0.65 0.17 8)', label: 'Rose' }, - { value: 'orange', swatch: 'oklch(0.68 0.17 50)', label: 'Orange' }, - { value: 'green', swatch: 'oklch(0.65 0.15 150)', label: 'Green' }, - { value: 'teal', swatch: 'oklch(0.65 0.12 185)', label: 'Teal' }, +// cards (the live --primary values would vanish against one of them). Names are +// translated via options.accentNames.. +const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [ + { value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' }, + { value: 'blue', swatch: 'oklch(0.65 0.17 255)' }, + { value: 'indigo', swatch: 'oklch(0.65 0.19 277)' }, + { value: 'purple', swatch: 'oklch(0.65 0.17 305)' }, + { value: 'rose', swatch: 'oklch(0.65 0.17 8)' }, + { value: 'orange', swatch: 'oklch(0.68 0.17 50)' }, + { value: 'green', swatch: 'oklch(0.65 0.15 150)' }, + { value: 'teal', swatch: 'oklch(0.65 0.12 185)' }, ]; const SPEED_UNITS = [ { value: 'MBps', label: 'MB/s' }, @@ -78,7 +81,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @if (panels.workspaces().length > 1) { } @@ -147,17 +167,17 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @if (settings.effectiveDark()) {
- Tone + {{ i18n.t('options.tone.label') }}
@for (tone of darkTones; track tone) { }
@@ -165,12 +185,12 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; }
- Accent + {{ i18n.t('options.accent.label') }}
@for (tone of accents; track tone.value) { }
- Interface + {{ i18n.t('options.interface.label') }}
@for (size of textSizes; track size) { }
- Speed unit + {{ i18n.t('options.speedUnit.label') }}
@for (unit of speedUnits; track unit.value) { }
@@ -252,7 +272,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @if (settings.transferMode() === 'multi') {
- At once + {{ i18n.t('options.atOnce') }}
@for (count of concurrencyChoices; track count) {
@@ -303,12 +323,12 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; (click)="pickEditorApp()" class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent" > - Choose… + {{ i18n.t('common.choose') }} @if (settings.editorApp()) {
@@ -374,8 +394,11 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; export class TabBar { readonly panels = inject(PanelsService); readonly settings = inject(SettingsService); + readonly i18n = inject(I18nService); private readonly rpc = inject(RpcService); readonly settingsOpen = signal(false); + readonly localeOptions = SUPPORTED; + readonly localeLabels = LOCALE_LABELS; readonly isMacChrome = IS_MAC_CHROME; readonly modLabel = MOD_LABEL; readonly appName = BRANDING.appName; @@ -413,7 +436,7 @@ export class TabBar { /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */ editorAppName(): string { const path = this.settings.editorApp(); - if (!path) return 'System default'; + if (!path) return this.i18n.t('options.systemDefault'); const base = path.split('/').filter(Boolean).pop() ?? path; return base.replace(/\.app$/i, ''); } diff --git a/ui/src/app/transfer-bar/transfer-bar.html b/ui/src/app/transfer-bar/transfer-bar.html index 79f5d07..f31bd5e 100644 --- a/ui/src/app/transfer-bar/transfer-bar.html +++ b/ui/src/app/transfer-bar/transfer-bar.html @@ -1,14 +1,14 @@ @if (transfers.expanded() && transfers.hasJobs()) {
- {{ transfers.activeJobs().length }} active · {{ transfers.finishedJobs().length }} finished + {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
@@ -30,7 +30,7 @@ @if (isActive(job)) {