From c3edca1b9d44de178e7d8338d4a0b80144cd6fc7 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:56:01 +0530 Subject: [PATCH 01/38] docs(proposals): propose localization for the desktop app in English, Japanese and German --- docs/proposals/localization.md | 179 +++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/proposals/localization.md diff --git a/docs/proposals/localization.md b/docs/proposals/localization.md new file mode 100644 index 0000000000..3dd1aa1b38 --- /dev/null +++ b/docs/proposals/localization.md @@ -0,0 +1,179 @@ +--- +status: proposed +code: + - surfsense_local/frontend/translations/ + - surfsense_local/frontend/translation-context.json + - surfsense_local/frontend/translations.inlang/ + - surfsense_local/frontend/src/i18n/ + - surfsense_local/electron/src/main/i18n/ + - .agents/skills/translate/ +--- + +# Localization + +> The desktop app's interface in English, Japanese and German, chosen from the OS languages or by the user. Translations are JSON files in the repo, compiled into the app, so nothing is fetched at run time. Paraglide JS compiles them today; the files, folder names and call sites do not name it, so it can be replaced without touching components. + +## Languages + +English is the source. Japanese and German come first, in the order [`03-international.md`](../../plans/community-local/seo/03-international.md) set for the website: Japan is the second-largest market with no competitor on `ローカルLLM`, and Germany the largest non-English "alternative" demand. French, Spanish and Brazilian Portuguese are the next tier there and are added here as files only when the website's pages in those languages exist. + +## What gets translated + +Only text written into the app's own code. Anything a user or a model produces, and anything the backend sends as data, is shown as it is. + +| Translated | Not translated | +|---|---| +| Labels, buttons, headings, menus, tooltips, placeholders, empty states, toasts, dialogs | User content: workspace, document and thread names, file contents, chat messages | +| Text read aloud or shown on hover: `aria-label`, `title`, `alt` | Model output: chat answers and Studio artifacts | +| Fixed sentences with a value inserted, `Deleted {name}` or a plural; the sentence is translated, the value is passed in unchanged | Backend and manifest data: model names and descriptions, provider names, file paths | +| The frontend's message for each backend error code (see [Backend text](#backend-text)) | Product and technical names: SurfSense, Studio, llama.cpp, GGUF | +| Main-process menu labels (see [Main process](#main-process)) | Logs, console output and developer-only text | + +Model descriptions in the manifest are fixed text, but they reach the frontend as data from the backend, so they stay English. Translating them is catalog work, not part of this proposal. + +The language a chat answer or a Studio output is written in is also out of scope. The chat already answers in the question's language ([chat](../architecture/chat.md)), and a podcast brief carries its own `language` ([Studio](../architecture/studio.md)). Both are independent of the interface language. + +## Library + +[Paraglide JS 2](https://github.com/opral/paraglide-js). Weighed against: + +| Library | Runtime, gzipped | Why not | +|---|---|---| +| Paraglide JS 2 | about 1 KB; unused messages are tree-shaken | chosen | +| Lingui 5 | about 5 KB | its macros need Babel or SWC, which Rolldown cannot run; Vite 8 and `@vitejs/plugin-react` 6 would have to bring one back | +| react-i18next | 15 to 20 KB, plus i18next | the heaviest; the usual Electron setup (`i18next-electron-fs-backend`) loads JSON over IPC at run time, a problem a bundled app does not have; keys are untyped | +| react-intl (FormatJS) | about 18 KB | a runtime ICU parser for messages that can be compiled ahead | + +Size matters less in an app loaded from disk. What decides it is that Paraglide compiles each message to a typed ES module function, which runs unchanged in the renderer (Vite) and in the main process (Node): a wrong key or parameter is a type error, and there is no provider or runtime state to keep in sync across the two processes. + +## Keeping the library replaceable + +Four layers. Only the build config knows the library. + +**Files are ICU MessageFormat 1.** One JSON file per language, `{"key": "ICU string"}`, stored through inlang's [ICU MessageFormat v1 plugin](https://inlang.com/m/p7c8m1d2/plugin-inlang-icu-messageformat-1): `plural`, `select`, `selectordinal`, `=n`, `#` and number and date formatters. FormatJS, i18next with `i18next-icu`, Lingui and every major translation platform read the same files. inlang's own format writes plurals as `declarations`, `selectors` and `match` objects that only inlang reads, so it is not used. + +**Names are generic.** + +``` +surfsense_local/frontend/ + translations/ en.json, ja.json, de.json + translation-context.json one line of context per key, for the translator + translations.inlang/ settings.json: locales, plugin, pathPattern + src/i18n/ + compiled/ compiler output, gitignored + messages.ts the only import the app uses + locale.ts supported locales, current locale, change locale +surfsense_local/electron/src/main/i18n/ + compiled/ same messages, compiled for main, gitignored + locale.ts detect, store and broadcast the locale +``` + +The inlang project folder must end in `.inlang`; the name before it is free. The compiler writes wherever `outdir` points. Replacing the library deletes `translations.inlang/` and the `compiled/` folders and rewrites `messages.ts`. + +**Call sites use one shape.** Components call `messages.sources_delete_confirm_title({ name })`, one typed function per key, imported from `@/i18n`. That is what Paraglide emits; another library satisfies it with a typed proxy, `(params) => t("sources_delete_confirm_title", params)`, and no component changes. An ESLint `no-restricted-imports` rule forbids importing `compiled/` from outside `src/i18n/`. + +**The locale belongs to the app.** Main owns it; the renderer is told. The IPC channel and the stored preference never name the library. + +## Shape of `en.json` + +```json +{ + "chat_composer_placeholder": "Ask about your sources", + "chat_composer_send_button": "Send", + "chat_error_provider_auth": "Your model connection needs a new API key.", + "settings_general_language_label": "Language", + "sources_delete_cancel_button": "Cancel", + "sources_delete_confirm_body": "Delete {count, plural, one {# document} other {# documents}}? This cannot be undone.", + "sources_delete_confirm_title": "Delete “{name}”?", + "sources_list_empty": "No documents yet. Add a file or a folder to start." +} +``` + +**Keys.** `__`, snake_case. + +- `` is a folder under `src/features/` (`chat`, `sources`, `settings`), or `app` for the shell and `menu` for main. A check fails on any other prefix, so deleting a feature's folder shows its keys as dead. +- `` is the component or dialog: `composer`, `delete`, `list`. +- `` names what the text does, never what it says: `_title`, `_body`, `_label`, `_placeholder`, `_button`, `_tooltip`, `_empty`, `_error`, `_toast`, `_aria`. The suffix tells a translator how much room there is. `sources_delete_confirm_title`, not `are_you_sure`. +- The text for a backend code is `_error_`, the code as the backend sends it: `chat_error_provider_auth`. +- A key names the meaning. Rewording that keeps the meaning keeps the key and the skill retranslates it; a new meaning is a new key. + +Paraglide's own docs recommend [random readable keys](https://paraglidejs.com/message-keys) (`penguin_purple_shoe`), so a key never has to be renamed when the interface moves. Keys here carry meaning instead, as [Locize](https://www.locize.com/blog/guide-to-i18n-key-naming) and [Lokalise](https://lokalise.com/blog/translation-keys-naming-and-organizing/) recommend: the feature prefix is what lets the check find a deleted feature's keys, and the key is the first context a translator sees. The cost is a rename when a string moves to another feature. + +**No shared strings.** "Cancel" in a delete dialog and "Cancel" in a download are two keys. German may want *Abbrechen* in one and *Verwerfen* in the other, and a feature's keys leave with the feature. Repetition in `en.json` is expected; a `common` block is not allowed. + +**Whole sentences.** A value is a sentence or a label, never a piece of one. No joining two keys, no leading or trailing space, no label ending in a colon followed by a value in code. Japanese puts the verb last and German moves it, so a sentence built from parts cannot be translated. A value inserted mid-sentence is a named placeholder: `{name}`, `{count}`, `{size}`, never `{0}`. + +**Plurals and choices are ICU.** `{count, plural, one {# document} other {# documents}}`. `=0` only when zero reads differently ("No documents"). Never `(s)`, never a `_one` and `_other` key pair. Each language writes the categories its CLDR rules have: Japanese only `other`, German `one` and `other`. The file check compares against those categories, not against English's. + +**Literal keys only.** Code calls `messages.sources_list_empty()`, never `messages[key]`. A backend code maps to text through a typed record, so a new code without a message is a type error: + +```ts +const chatErrorMessage: Record string> = { + provider_auth: messages.chat_error_provider_auth, + // one entry per ChatErrorKind +} +``` + +**Sorted.** Keys in each file sorted, two-space indent, one key per line, the same order in every language. A feature's keys sit together, diffs stay small, and two pull requests adding keys to different features rarely conflict. A formatter writes it; the check fails on an unsorted file. + +**Other languages** hold exactly the keys of `en.json`: none missing, none extra. A missing key shows English, never the key name. + +**Context for the translator.** A key and its English do not always say enough: "Open" can be a verb or a state, and `{name}` can be a file or a workspace. `translation-context.json`, beside `translations/`, gives such a key one line on where it appears and what fills its placeholders: + +```json +{ + "sources_delete_confirm_title": "Title of the dialog that deletes one source. {name} is the file name.", + "sources_list_empty": "Shown in the Sources panel when the workspace has no documents." +} +``` + +It sits outside `translations/` so the compiler and any translation platform never read it as a language. Only the `translate` skill reads it. The check fails when a key with a placeholder, or a value of three words or fewer, has no entry, and when an entry names a key `en.json` does not have. Most guides also suggest screenshots; they are left out until a translator who is not an agent asks for them. + +## Locale + +- **Detect.** On first launch, the first entry of `app.getPreferredSystemLanguages()` whose base language is supported (`ja-JP` → `ja`), else `en`. +- **Override.** Settings › General gets a Language select beside Appearance: System, English, 日本語, Deutsch, each language written in itself. The choice is saved as `locale-prefs.json` in `userData`, the way [`theme-prefs.ts`](../../surfsense_local/electron/src/main/theme-prefs.ts) saves the theme. +- **Hand over.** Preload reads the locale synchronously before the renderer's first paint, as it does the system theme, and exposes `locale.set()` and `locale.onChange()`. `src/i18n/locale.ts` passes it to the compiled runtime with `overwriteGetLocale()`. +- **Change.** A change re-renders the React root under a new `key` and calls `installProductionMenu()` again in main. No restart. +- **Formatting.** Dates, numbers and relative times go through `Intl` with the current locale. The frontend calls `Intl` or `toLocale*` in 13 places today; each takes the locale from `src/i18n/locale.ts`. +- **Html.** `` follows the locale, so the OS picks the right CJK glyphs and screen readers the right voice. + +## Main process + +Main shows three strings of its own: the View menu's "View", "Reload" and "Force Reload" in [`electron/src/main/index.ts`](../../surfsense_local/electron/src/main/index.ts). They are compiled from the same `translations/` into `src/main/i18n/compiled/`, as a step in `electron-vite build`. Main has one user, so a module-level locale behind `overwriteGetLocale()` is safe; the `AsyncLocalStorage` pattern Paraglide documents is for servers. + +The `role` menus (`appMenu`, `fileMenu`, `editMenu`, `windowMenu`) take their labels from Electron and the OS. On macOS they are only translated if the app ships that language's `.lproj`, so `mac.electronLanguages` in [`electron-builder.yml`](../../surfsense_local/electron/electron-builder.yml) must include `en`, `ja` and `de` if it is ever narrowed ([electron#26231](https://github.com/electron/electron/issues/26231)). + +## Backend text + +The backend returns some English prose the UI shows as is: the chat's error messages in [`modules/chat/errors.py`](../../surfsense_local/backend/modules/chat/errors.py), and `detail` strings that [`lib/api.ts`](../../surfsense_local/frontend/src/lib/api.ts) surfaces. The backend already sends a code with each (`provider_auth`, `egress_disabled`); the frontend translates the code and falls back to the English text for a code it does not know. The backend stays English and gains no i18n. + +## Translating + +1. **Developers write English only.** A new string is one line in `en.json` and a `messages.*()` call. +2. **An agent translates.** The `translate` skill below fills in Japanese and German for every key that is new or whose English changed. +3. **Review is open.** Anyone who reads the language can correct a translation in a pull request; no release waits on it. +4. **CI checks the files.** A script fails the build on anything [Shape of `en.json`](#shape-of-enjson) forbids: a key missing from or extra to a language, placeholders or select branches that differ from the English, plural categories that do not match the language's CLDR rules, a value with markup or a leading or trailing space, a key prefix that is not a feature, a key no code in the frontend or in Electron main calls, a key that needs context and has none, an unsorted file. + +`pnpx @inlang/cli machine translate` is not used: it sends every string to an outside translation service. The strings are not user data, but the app's position is that nothing leaves without a decision ([ADR 0017](../adr/0017-egress-off-by-default.md)), and a skill run by a developer is that decision. + +## The `translate` skill + +`.agents/skills/translate/`, top level, because it runs commands. + +- **Glossary.** Terms that stay in English (SurfSense, Studio, Workspace, llama.cpp), and the chosen term per language for the rest, taken from the per-language term table in `03-international.md` so the app and the website use the same words (`ローカルLLM`, `lokale KI`). +- **Tone.** Japanese in です/ます, the default in Microsoft's Japanese style guide, with the plain form for short labels and 〜してください for instructions. German in *du*, which macOS has used since Sierra; never mixed with *Sie*. +- **Steps.** Find the keys to translate: those missing from a language, and those whose English differs from `en.json` at the last commit that changed that language's file. Read each changed key's line in `translation-context.json`, translate the changed keys, keep every placeholder and ICU branch, run the file check, stop on a failure. +- **Limits.** Never reword English. Never translate a key name. Flag a string whose meaning is unclear instead of guessing. + +## Rich text + +A sentence with a link or bold span is where libraries differ most, and the ICU plugin stores tags as plain text. Splitting it into keys around the link breaks the whole-sentence rule, so the copy is written without it: the sentence stands alone and the action is its own button or link with its own key. The check fails on a value that contains markup. + +## Build order + +1. **Spike.** Confirm the ICU plugin compiles under Paraglide 2 with the Vite 8 plugin and under the CLI for main, and that `modules` in `settings.json` can point at a local copy of the plugin instead of jsDelivr, so a build needs no network. If either fails, fall back to inlang's JSON plugin with simple `{placeholder}` messages and keep plurals out until it is fixed. +2. **Seam.** `src/i18n/`, `translations/en.json`, `translation-context.json`, the lint rule, the file check and formatter, the preload bridge and the Language setting, with one feature moved to prove the path. +3. **Extract.** Move the remaining strings feature by feature, one pull request per feature under `src/features/`. The frontend has about 120 component files. +4. **Translate.** The skill, then `ja.json` and `de.json`. +5. **Ship.** Fold what is true into `docs/architecture/localization.md`, record the library choice and the ICU file format as ADRs, delete this proposal. From fa55a0ec52a753a32ae4178d89e00c255de23374 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:00:08 +0530 Subject: [PATCH 02/38] docs(localization): add spike findings and run the translation check as a pre-commit hook --- docs/proposals/localization.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/proposals/localization.md b/docs/proposals/localization.md index 3dd1aa1b38..dd9b46a63a 100644 --- a/docs/proposals/localization.md +++ b/docs/proposals/localization.md @@ -7,6 +7,7 @@ code: - surfsense_local/frontend/src/i18n/ - surfsense_local/electron/src/main/i18n/ - .agents/skills/translate/ + - scripts/check_translations.py --- # Localization @@ -114,7 +115,7 @@ const chatErrorMessage: Record string> = { } ``` -**Sorted.** Keys in each file sorted, two-space indent, one key per line, the same order in every language. A feature's keys sit together, diffs stay small, and two pull requests adding keys to different features rarely conflict. A formatter writes it; the check fails on an unsorted file. +**Sorted.** Keys in each file sorted, tab-indented, one key per line, the same order in every language. A feature's keys sit together, diffs stay small, and two pull requests adding keys to different features rarely conflict. Tabs because the ICU plugin writes tabs, so an inlang tool that saves a file does not reformat all of it. A formatter writes it; the check fails on an unsorted file. **Other languages** hold exactly the keys of `en.json`: none missing, none extra. A missing key shows English, never the key name. @@ -153,7 +154,8 @@ The backend returns some English prose the UI shows as is: the chat's error mess 1. **Developers write English only.** A new string is one line in `en.json` and a `messages.*()` call. 2. **An agent translates.** The `translate` skill below fills in Japanese and German for every key that is new or whose English changed. 3. **Review is open.** Anyone who reads the language can correct a translation in a pull request; no release waits on it. -4. **CI checks the files.** A script fails the build on anything [Shape of `en.json`](#shape-of-enjson) forbids: a key missing from or extra to a language, placeholders or select branches that differ from the English, plural categories that do not match the language's CLDR rules, a value with markup or a leading or trailing space, a key prefix that is not a feature, a key no code in the frontend or in Electron main calls, a key that needs context and has none, an unsorted file. +4. **A file check guards the files.** `scripts/check_translations.py`, a pre-commit hook scoped to `surfsense_local/frontend/translations/` and `translation-context.json`, the way `check_docs.py` guards `docs/`. There is no pull request CI for `surfsense_local` yet, but [`code-quality.yml`](../../.github/workflows/code-quality.yml) runs every pre-commit hook on a non-draft pull request's changed files, so this one runs there too. Python, because that job sets up Python and not Node. It fails on anything [Shape of `en.json`](#shape-of-enjson) forbids: a key missing from or extra to a language, placeholders or select branches that differ from the English, plural categories that do not match the language's CLDR rules, a value with markup or a leading or trailing space, a key prefix that is not a feature, a key no code in the frontend or in Electron main calls, a key that needs context and has none, an unsorted file. +5. **The build guards the compile.** `pnpm build` fails when the compiler emits zero messages or Rollup warns that a name `is not exported by` the compiled messages: a wrong plugin key or `pathPattern` otherwise compiles nothing, exits 0, and the app fails at run time with `(void 0) is not a function` ([globalize-skills#82](https://github.com/globalize-now/globalize-skills/pull/82)). That runs on every local build and in [`release-local.yml`](../../.github/workflows/release-local.yml), and on pull requests once the desktop CI in the [roadmap](../ROADMAP.md) exists. `pnpx @inlang/cli machine translate` is not used: it sends every string to an outside translation service. The strings are not user data, but the app's position is that nothing leaves without a decision ([ADR 0017](../adr/0017-egress-off-by-default.md)), and a skill run by a developer is that decision. @@ -172,8 +174,8 @@ A sentence with a link or bold span is where libraries differ most, and the ICU ## Build order -1. **Spike.** Confirm the ICU plugin compiles under Paraglide 2 with the Vite 8 plugin and under the CLI for main, and that `modules` in `settings.json` can point at a local copy of the plugin instead of jsDelivr, so a build needs no network. If either fails, fall back to inlang's JSON plugin with simple `{placeholder}` messages and keep plurals out until it is fixed. -2. **Seam.** `src/i18n/`, `translations/en.json`, `translation-context.json`, the lint rule, the file check and formatter, the preload bridge and the Language setting, with one feature moved to prove the path. +1. **Smoke test.** Paraglide 2 lists the ICU plugin as a supported format, and inlang loads a plugin from `./node_modules/@inlang/plugin-icu1/dist/index.js`, so a build needs no network. The settings key is `plugin.inlang.icu-messageformat-1`. Pin `@inlang/paraglide-js` 2.25.4 and `@inlang/plugin-icu1` 1.1.0. What is left to prove: one plural and one placeholder compile and render, in the renderer through the Vite 8 plugin and in main through the CLI. +2. **Seam.** `src/i18n/`, `translations/en.json`, `translation-context.json`, the lint rule, the file check hook, the formatter, the build guard, the preload bridge and the Language setting, with one feature moved to prove the path. 3. **Extract.** Move the remaining strings feature by feature, one pull request per feature under `src/features/`. The frontend has about 120 component files. 4. **Translate.** The skill, then `ja.json` and `de.json`. 5. **Ship.** Fold what is true into `docs/architecture/localization.md`, record the library choice and the ICU file format as ADRs, delete this proposal. From 45b18f62b2e05c5ecb31984959bd9d3293f07fd5 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:48:08 +0530 Subject: [PATCH 03/38] feat(skills): add a translate skill for the desktop app's interface strings, tone and glossary --- .agents/skills/translate/SKILL.md | 57 ++++++++++++++++++++++++++++ .agents/skills/translate/glossary.md | 30 +++++++++++++++ AGENTS.md | 1 + surfsense_local/AGENTS.md | 1 + 4 files changed, 89 insertions(+) create mode 100644 .agents/skills/translate/SKILL.md create mode 100644 .agents/skills/translate/glossary.md diff --git a/.agents/skills/translate/SKILL.md b/.agents/skills/translate/SKILL.md new file mode 100644 index 0000000000..5f5a093451 --- /dev/null +++ b/.agents/skills/translate/SKILL.md @@ -0,0 +1,57 @@ +--- +name: translate +description: Adding, changing or translating interface strings in the SurfSense desktop app (surfsense_local). Use when writing a key in translations/en.json, moving hard-coded UI text into messages, or filling in ja.json and de.json. Owns the key shape, the tone per language and the glossary. +--- + +# Translate + +The desktop app's interface text lives in `surfsense_local/frontend/translations/`, one ICU MessageFormat file per language. English is the source; Japanese and German are translated from it. Design: [`docs/proposals/localization.md`](../../../docs/proposals/localization.md) (or `docs/architecture/localization.md` once it ships). + +| File | Holds | +|---|---| +| `translations/en.json`, `ja.json`, `de.json` | `{"key": "ICU string"}`, flat, sorted, tab-indented | +| `translation-context.json` | one line per key on where it appears and what fills its placeholders | +| [`glossary.md`](glossary.md) | terms that stay English, and the chosen term per language | + +Only fixed text in the app's code is translated. Never user content, model output, model names or descriptions, file paths, or logs. + +## Adding English + +- Key: `__`, snake_case. `` is a folder under `frontend/src/features/`, or `app` for the shell, `menu` for Electron main. `` is one of `_title`, `_body`, `_label`, `_placeholder`, `_button`, `_tooltip`, `_empty`, `_error`, `_toast`, `_aria`. +- Name the meaning, not the words: `sources_delete_confirm_title`, not `are_you_sure`. +- A backend error code maps to `_error_`. +- One key per place. Never reuse another feature's "Cancel"; never add a `common` key. +- A whole sentence per value. No joining keys, no leading or trailing space, no markup. A link or bold span becomes its own key and its own element. +- Named placeholders only: `{name}`, `{count}`. Plurals are ICU: `{count, plural, one {# document} other {# documents}}`. Never `(s)`. +- Call it literally: `messages.sources_list_empty()`, never `messages[key]`. +- Add a line to `translation-context.json` when the key has a placeholder or the value is three words or fewer. +- Keep the file sorted. Add the same key to `ja.json` and `de.json` in the same change, translated with the steps below. + +## Translating + +1. **Find the keys.** A key needs work in a language when it is missing there, or when its English changed since that language was last translated: + + ```bash + cd surfsense_local/frontend + base=$(git log -1 --format=%H -- translations/ja.json) + git diff "$base" -- translations/en.json + ``` + + Repeat with `de.json`. Every added or changed line in that diff is a key to translate. +2. **Read the context.** Its line in `translation-context.json`, then the component that calls it (`grep -rn "messages." src`). Translate the meaning in that place, not the English words. +3. **Translate.** Follow the tone below and [`glossary.md`](glossary.md). Keep every placeholder name and every `select` branch exactly. Write the plural categories the language has, not the English ones: Japanese only `other`, German `one` and `other`. +4. **Check.** `pre-commit run check-translations --all-files`. Stop and fix on any failure; do not commit around it. + +## Tone + +- **Japanese.** です/ます for sentences, the default in Microsoft's Japanese style guide. Plain form for short labels and buttons (`保存`, `削除`). 〜してください for instructions. Full-width punctuation (`。`, `、`), no space between Japanese and Latin text unless the product name needs it. +- **German.** *du*, as macOS has used since Sierra. Never *Sie*, never mixed. Capitalize *du* only at the start of a sentence. Buttons are infinitives (`Speichern`, `Löschen`). +- Both: as short as the English allows. A `_button` or `_label` has the room its English has. + +## Limits + +- Never reword English while translating. A problem with the English is a separate change. +- Never translate a key name or a placeholder name. +- Never guess. When a key's meaning is unclear from its context line and its component, stop and say which key and why. +- Never send strings to an outside translation service or CLI. Nothing leaves without a decision ([ADR 0017](../../../docs/adr/0017-egress-off-by-default.md)). +- A new term that will recur goes into [`glossary.md`](glossary.md) in the same change. diff --git a/.agents/skills/translate/glossary.md b/.agents/skills/translate/glossary.md new file mode 100644 index 0000000000..cc5b29b8fe --- /dev/null +++ b/.agents/skills/translate/glossary.md @@ -0,0 +1,30 @@ +# Glossary + +One term per concept, so the app says the same thing everywhere. Terms marked with † come from the per-language search data in [`03-international.md`](../../../plans/community-local/seo/03-international.md#glossaries), so the app matches the website. Add a row when a term will recur; change a row only with every key that uses it. + +## Stays in English + +SurfSense, Studio, Workspace, llama.cpp, GGUF, Hugging Face, API, MCP, and every model and provider name. + +## Per language + +| Concept | Japanese | German | +|---|---|---| +| local LLM | ローカルLLM † | lokales LLM † | +| local AI | ローカルAI † | lokale KI † | +| offline | オフライン † | offline † | +| open source | オープンソース † | Open Source † | +| privacy | プライバシー | Datenschutz † | +| source (an added file or folder) | ソース | Quelle | +| document | ドキュメント | Dokument | +| chat | チャット | Chat | +| model | モデル | Modell | +| download | ダウンロード † | herunterladen | +| settings | 設定 | Einstellungen | +| slides | スライド † | Präsentation † | +| summary | 要約 † | Zusammenfassung † | +| mind map | マインドマップ † | Mindmap † | +| flashcards | フラッシュカード | Karteikarten † | +| quiz | クイズ | Quiz † | +| study guide | 学習ガイド | Lernzettel † | +| podcast | ポッドキャスト | Podcast | diff --git a/AGENTS.md b/AGENTS.md index 1792bbb171..63e4be5ccc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ Canonical dir: `.agents/skills/`. `.claude/skills` is a symlink to it. | `frontend-workflow` | Frontend work in `surfsense_web` and `surfsense_local/frontend`: React/Next performance, color tokens, UI polish, motion. Bundles `react-performance/`, `color/`, `polish/`, `motion/`; routes out to `shadcn`. | | `shadcn` | UI components in a tree with `components.json`. Standalone — it inspects the project live and grants its own CLI, which only works as a discovered skill. Load it after `frontend-workflow`, not instead of it. | | `migrate-radix-to-base` | Radix UI → Base UI migration. Stays top-level; not part of `frontend-workflow`. | +| `translate` | Interface strings in `surfsense_local`: adding a key to `translations/en.json`, moving hard-coded text into messages, translating Japanese and German. Holds the key shape, tone and glossary. | Do not install skill catalogs. Do not add `CONTEXT.md` or a second rules tree. diff --git a/surfsense_local/AGENTS.md b/surfsense_local/AGENTS.md index ac456a2944..3c69ce3170 100644 --- a/surfsense_local/AGENTS.md +++ b/surfsense_local/AGENTS.md @@ -20,6 +20,7 @@ Version is `VERSION`. `scripts/bump-version.sh` writes it through the desktop pa - React UI, colors, polish, motion in `frontend/`: `frontend-workflow` skill. - shadcn components (`frontend/components.json`): `shadcn` skill after `frontend-workflow`, not instead of it — separate, and it inspects the project live. - New behavior: `tdd` skill. +- Interface text in `frontend/` or a main-process menu: `translate` skill. ## Do not From df39658940c72067dccbe41d87f9bb4b91d4a10c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:08:55 +0530 Subject: [PATCH 04/38] build(local-ui): add FormatJS and precompile translation catalogs without the ICU parser --- surfsense_local/frontend/.gitignore | 3 + surfsense_local/frontend/eslint.config.js | 11 ++ surfsense_local/frontend/package.json | 11 +- surfsense_local/frontend/pnpm-lock.yaml | 143 +++++++++++++++++++ surfsense_local/frontend/pnpm-workspace.yaml | 10 ++ surfsense_local/frontend/tsconfig.app.json | 1 + surfsense_local/frontend/vite.config.ts | 4 + 7 files changed, 179 insertions(+), 4 deletions(-) diff --git a/surfsense_local/frontend/.gitignore b/surfsense_local/frontend/.gitignore index a547bf36d8..1fb74a4915 100644 --- a/surfsense_local/frontend/.gitignore +++ b/surfsense_local/frontend/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# formatjs compile-folder output (pnpm translations) +src/i18n/compiled diff --git a/surfsense_local/frontend/eslint.config.js b/surfsense_local/frontend/eslint.config.js index 08e3235dcc..04334343d6 100644 --- a/surfsense_local/frontend/eslint.config.js +++ b/surfsense_local/frontend/eslint.config.js @@ -23,4 +23,15 @@ export default defineConfig([ files: ['src/components/ui/**'], rules: { 'react-refresh/only-export-components': 'off' }, }, + { + // The compiled catalogs are an implementation detail of src/i18n/intl.ts. + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/i18n/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { patterns: ['**/i18n/compiled/*', '**/translations/*.json'] }, + ], + }, + }, ]) diff --git a/surfsense_local/frontend/package.json b/surfsense_local/frontend/package.json index f289638f07..7eedd1e558 100644 --- a/surfsense_local/frontend/package.json +++ b/surfsense_local/frontend/package.json @@ -4,12 +4,13 @@ "version": "2.0.2", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", + "dev": "pnpm translations && vite", + "translations": "formatjs compile-folder translations src/i18n/compiled --format simple --ast", + "build": "pnpm translations && tsc -b && vite build", "lint": "eslint .", "format": "prettier --write \"**/*.{ts,tsx}\"", - "test": "vitest run --environment jsdom", - "typecheck": "tsc -b", + "test": "pnpm translations && vitest run --environment jsdom", + "typecheck": "pnpm translations && tsc -b", "preview": "vite preview" }, "dependencies": { @@ -38,6 +39,7 @@ "react": "^19.2.6", "react-data-grid": "7.0.0-beta.61", "react-dom": "^19.2.6", + "react-intl": "12.1.3", "shadcn": "^4.20.1", "sonner": "^2.0.8", "ssf": "^0.11.2", @@ -48,6 +50,7 @@ }, "devDependencies": { "@eslint/js": "^10", + "@formatjs/cli": "6.16.32", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^24", diff --git a/surfsense_local/frontend/pnpm-lock.yaml b/surfsense_local/frontend/pnpm-lock.yaml index 390fe9b26a..c02b909d15 100644 --- a/surfsense_local/frontend/pnpm-lock.yaml +++ b/surfsense_local/frontend/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.8(react@19.2.8) + react-intl: + specifier: 12.1.3 + version: 12.1.3(@types/react@19.2.18)(react@19.2.8) shadcn: specifier: ^4.20.1 version: 4.20.1(typescript@6.0.3) @@ -108,6 +111,9 @@ importers: '@eslint/js': specifier: ^10 version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) + '@formatjs/cli': + specifier: 6.16.32 + version: 6.16.32 '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -520,6 +526,80 @@ packages: '@fontsource/ibm-plex-mono@5.3.0': resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + '@formatjs/cli-native-darwin-arm64@1.1.26': + resolution: {integrity: sha512-NWngBSWyhM5BPi2wOZznDdZG5rCVJceWwNoOhYwn2mTyNMkjxYOuZaBta8/sdaVsLoQMdFCa9A0iLl6H1Rse0Q==} + cpu: [arm64] + os: [darwin] + + '@formatjs/cli-native-linux-arm64-musl@1.0.24': + resolution: {integrity: sha512-FQ64DANaY/bOP8GDt/gTylXoTNVmPEP0x6BkH0fqtJ9CeNmAfzXX6cfmvGq32tpP9hLoRFWDOD+Nf0J9u+7AKg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@formatjs/cli-native-linux-arm64@1.2.26': + resolution: {integrity: sha512-kxBNVqBkzIrD5zcj6s26mh4I0eYFKG8sS+Z6gURtjQvGIAn70Rd513ueKKPM7Bi0myUj3Xmmuu6JrR1AoFNBEw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@formatjs/cli-native-linux-x64-musl@1.0.24': + resolution: {integrity: sha512-BoGbW8mstaiyBEccnN1+oEymuk1UfUyiXv0P2i6UkQ/Ebe9eme/S40cEZCAJV9RfkCAplEmjKzFXvSyYttPuYw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@formatjs/cli-native-linux-x64@1.1.26': + resolution: {integrity: sha512-WKG+IX95QvK3Dp2ZAtLDDqZTJJeEXMdlvH+9odJLfRYOF3C9R5J/uXkRKgNLwstI7HZH6FXDZN9eY3rL2l+lwg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@formatjs/cli-native-win32-x64@1.1.27': + resolution: {integrity: sha512-3zVqRflzXHW4ZiFfCgo4vniVCDy6zcKU/kc700bBioK0ohnpOuUxH+8T72RdftDQLxf8iTGig66zARYkcI30Ow==} + cpu: [x64] + os: [win32] + + '@formatjs/cli@6.16.32': + resolution: {integrity: sha512-b+juh9s0edVpdZg39g+8WyZthNWf2N4xjBfcJpKfk0pz5Mfc0IriObDJC2r9BW7EzPknHjC5QpeBcMDRJfgWUA==} + engines: {node: '>= 20.12.0'} + hasBin: true + peerDependencies: + '@glimmer/env': '*' + '@glimmer/reference': '*' + '@glimmer/syntax': ^0.84.3 || ^0.95.0 + '@glimmer/validator': '*' + '@vue/compiler-core': '3' + content-tag: '4' + vue: '3' + peerDependenciesMeta: + '@glimmer/env': + optional: true + '@glimmer/reference': + optional: true + '@glimmer/syntax': + optional: true + '@glimmer/validator': + optional: true + '@vue/compiler-core': + optional: true + content-tag: + optional: true + vue: + optional: true + + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.20': + resolution: {integrity: sha512-DUzCb2iskGDKvLU7P+nz/rjpmN59luoTRbWHFFNYNnZTxWCKvwPI94rNll99TGZaI6XoTbDF0QGegk21GFrQZQ==} + + '@formatjs/icu-skeleton-parser@2.1.12': + resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==} + + '@formatjs/intl@6.1.2': + resolution: {integrity: sha512-YH3Ldfa2vmavWgBlCyrT9WiobphTXlNHnHxhhB6nLSUOxmitJ6Bzqzg61uBcnc6yhKTU+YRo8hLhEatOZrVJUg==} + '@gera2ld/jsx-dom@2.2.2': resolution: {integrity: sha512-EOqf31IATRE6zS1W1EoWmXZhGfLAoO9FIlwTtHduSrBdud4npYBxYAkv8dZ5hudDPwJeeSjn40kbCL4wAzr8dA==} @@ -2870,6 +2950,9 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + intl-messageformat@12.1.2: + resolution: {integrity: sha512-Y7ZnMtT/04nVJnjf1/x/AkfP5we/iN0AhckDdlFPtnetk6/VTuj2OPW5SE7steMd3UNaOtky4KJJY6jwyvWfJg==} + ip-address@10.7.0: resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==} engines: {node: '>= 12'} @@ -3900,6 +3983,12 @@ packages: peerDependencies: react: ^19.2.8 + react-intl@12.1.3: + resolution: {integrity: sha512-o14ogaAGSaYZMpyXcEnweedO2QoD1I5EdrZXq1iy+rFfhLWf8Nmr0YDCWtGwDAIPD/32ymnylpk7LGiS/AB5yw==} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -5128,6 +5217,47 @@ snapshots: '@fontsource/ibm-plex-mono@5.3.0': {} + '@formatjs/cli-native-darwin-arm64@1.1.26': + optional: true + + '@formatjs/cli-native-linux-arm64-musl@1.0.24': + optional: true + + '@formatjs/cli-native-linux-arm64@1.2.26': + optional: true + + '@formatjs/cli-native-linux-x64-musl@1.0.24': + optional: true + + '@formatjs/cli-native-linux-x64@1.1.26': + optional: true + + '@formatjs/cli-native-win32-x64@1.1.27': + optional: true + + '@formatjs/cli@6.16.32': + optionalDependencies: + '@formatjs/cli-native-darwin-arm64': 1.1.26 + '@formatjs/cli-native-linux-arm64': 1.2.26 + '@formatjs/cli-native-linux-arm64-musl': 1.0.24 + '@formatjs/cli-native-linux-x64': 1.1.26 + '@formatjs/cli-native-linux-x64-musl': 1.0.24 + '@formatjs/cli-native-win32-x64': 1.1.27 + + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.20': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.12 + + '@formatjs/icu-skeleton-parser@2.1.12': {} + + '@formatjs/intl@6.1.2': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.20 + intl-messageformat: 12.1.2 + '@gera2ld/jsx-dom@2.2.2': dependencies: '@babel/runtime': 7.29.7 @@ -7603,6 +7733,11 @@ snapshots: internmap@2.0.3: {} + intl-messageformat@12.1.2: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.20 + ip-address@10.7.0: {} ipaddr.js@1.9.1: {} @@ -8740,6 +8875,14 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-intl@12.1.3(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.20 + '@formatjs/intl': 6.1.2 + '@types/react': 19.2.18 + intl-messageformat: 12.1.2 + react: 19.2.8 + react-is@17.0.2: {} react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8): diff --git a/surfsense_local/frontend/pnpm-workspace.yaml b/surfsense_local/frontend/pnpm-workspace.yaml index 6afd1d41d7..35e740dbc8 100644 --- a/surfsense_local/frontend/pnpm-workspace.yaml +++ b/surfsense_local/frontend/pnpm-workspace.yaml @@ -3,3 +3,13 @@ packages: [] allowBuilds: esbuild: true msw: false + +minimumReleaseAgeExclude: + - react-intl@12.1.3 + - '@formatjs/cli-native-darwin-arm64@1.1.26' + - '@formatjs/cli-native-linux-arm64-musl@1.0.24' + - '@formatjs/cli-native-linux-arm64@1.2.26' + - '@formatjs/cli-native-linux-x64-musl@1.0.24' + - '@formatjs/cli-native-linux-x64@1.1.26' + - '@formatjs/cli-native-win32-x64@1.1.27' + - '@formatjs/cli@6.16.32' diff --git a/surfsense_local/frontend/tsconfig.app.json b/surfsense_local/frontend/tsconfig.app.json index 148bcc67d1..1c9d4f0352 100644 --- a/surfsense_local/frontend/tsconfig.app.json +++ b/surfsense_local/frontend/tsconfig.app.json @@ -11,6 +11,7 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, + "resolveJsonModule": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", diff --git a/surfsense_local/frontend/vite.config.ts b/surfsense_local/frontend/vite.config.ts index 08b537eb28..8413ea235f 100644 --- a/surfsense_local/frontend/vite.config.ts +++ b/surfsense_local/frontend/vite.config.ts @@ -11,6 +11,10 @@ export default defineConfig({ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), + // Every message is precompiled to AST by `pnpm translations`, so the ICU + // parser is dead weight at run time (FormatJS performance guide). + "@formatjs/icu-messageformat-parser": + "@formatjs/icu-messageformat-parser/no-parser.js", }, }, server: { From 3d4e416ff3e52a9910fc9b1004022ed393eba8c9 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:08:55 +0530 Subject: [PATCH 05/38] feat(i18n): bundle English, Japanese and German catalogs behind one intl instance --- surfsense_local/frontend/index.html | 3 + surfsense_local/frontend/src/i18n/intl.ts | 35 + .../frontend/src/i18n/locale.test.ts | 62 ++ surfsense_local/frontend/src/i18n/locale.ts | 13 + surfsense_local/frontend/src/i18n/locales.ts | 10 + .../frontend/src/i18n/message-ids.d.ts | 15 + surfsense_local/frontend/src/main.tsx | 25 +- surfsense_local/frontend/translations/de.json | 742 ++++++++++++++++++ surfsense_local/frontend/translations/en.json | 742 ++++++++++++++++++ surfsense_local/frontend/translations/ja.json | 742 ++++++++++++++++++ 10 files changed, 2380 insertions(+), 9 deletions(-) create mode 100644 surfsense_local/frontend/src/i18n/intl.ts create mode 100644 surfsense_local/frontend/src/i18n/locale.test.ts create mode 100644 surfsense_local/frontend/src/i18n/locale.ts create mode 100644 surfsense_local/frontend/src/i18n/locales.ts create mode 100644 surfsense_local/frontend/src/i18n/message-ids.d.ts create mode 100644 surfsense_local/frontend/translations/de.json create mode 100644 surfsense_local/frontend/translations/en.json create mode 100644 surfsense_local/frontend/translations/ja.json diff --git a/surfsense_local/frontend/index.html b/surfsense_local/frontend/index.html index b0dba5c141..efd3070154 100644 --- a/surfsense_local/frontend/index.html +++ b/surfsense_local/frontend/index.html @@ -24,6 +24,9 @@ const root = document.documentElement root.classList.add(theme) root.style.colorScheme = theme + // followMainLocale() in main.tsx sets it again; this covers the first frame. + const locale = window.surfsense?.locale?.get?.() + if (locale) root.lang = locale } catch { // no-op: fall back to the CSS defaults if storage/matchMedia throws } diff --git a/surfsense_local/frontend/src/i18n/intl.ts b/surfsense_local/frontend/src/i18n/intl.ts new file mode 100644 index 0000000000..fe961205e6 --- /dev/null +++ b/surfsense_local/frontend/src/i18n/intl.ts @@ -0,0 +1,35 @@ +import { + createIntl, + createIntlCache, + type MessageFormatElement, +} from "react-intl" + +import de from "./compiled/de.json" +import en from "./compiled/en.json" +import ja from "./compiled/ja.json" +import { BASE_LOCALE, isLocale, type Locale } from "./locales" + +type Catalog = Record + +// All three are bundled so text is there on the first render; a key a language +// lacks shows its English, never its id. +const catalogs: Record = { + en: en as Catalog, + ja: { ...(en as Catalog), ...(ja as Catalog) }, + de: { ...(en as Catalog), ...(de as Catalog) }, +} + +// Main owns the language and preload reports it before the first paint. No +// bridge (tests, a bare `vite`) or a language the app does not ship is English. +function reportedLocale(): Locale { + const reported = window.surfsense?.locale?.get() + return reported !== undefined && isLocale(reported) ? reported : BASE_LOCALE +} + +const locale = reportedLocale() + +// One instance for the page's life: a language change reloads the window. +export const intl = createIntl( + { locale, defaultLocale: BASE_LOCALE, messages: catalogs[locale] }, + createIntlCache() +) diff --git a/surfsense_local/frontend/src/i18n/locale.test.ts b/surfsense_local/frontend/src/i18n/locale.test.ts new file mode 100644 index 0000000000..0a198d48a1 --- /dev/null +++ b/surfsense_local/frontend/src/i18n/locale.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +function fakeBridge(initial: string) { + let listener: ((locale: string) => void) | undefined + window.surfsense = { + locale: { + get: () => initial, + preference: async () => "system", + set: async () => {}, + onChange: (next: (locale: string) => void) => { + listener = next + return () => (listener = undefined) + }, + }, + } as unknown as Window["surfsense"] + return { change: (locale: string) => listener?.(locale) } +} + +// intl is built once per page, from what preload reports at load. +async function loadPage() { + vi.resetModules() + const { intl } = await import("./intl") + const { followMainLocale } = await import("./locale") + return { intl, followMainLocale } +} + +describe("the page's language", () => { + afterEach(() => { + window.surfsense = undefined + }) + + it("starts in the language main reports and sets ", async () => { + fakeBridge("ja") + const { intl, followMainLocale } = await loadPage() + followMainLocale(() => {}) + expect(intl.locale).toBe("ja") + expect(document.documentElement.lang).toBe("ja") + expect(intl.formatMessage({ id: "menu_view_label" })).toBe("表示") + }) + + it("falls back to English for a language the app does not ship", async () => { + fakeBridge("fr") + const { intl } = await loadPage() + expect(intl.locale).toBe("en") + }) + + it("stays in English without the Electron bridge", async () => { + const { intl } = await loadPage() + expect(intl.formatMessage({ id: "menu_view_label" })).toBe("View") + }) + + it("reloads the window when main switches to another language", async () => { + const bridge = fakeBridge("en") + const { followMainLocale } = await loadPage() + const reload = vi.fn() + followMainLocale(reload) + bridge.change("en") + expect(reload).not.toHaveBeenCalled() + bridge.change("de") + expect(reload).toHaveBeenCalledOnce() + }) +}) diff --git a/surfsense_local/frontend/src/i18n/locale.ts b/surfsense_local/frontend/src/i18n/locale.ts new file mode 100644 index 0000000000..cabc70abd4 --- /dev/null +++ b/surfsense_local/frontend/src/i18n/locale.ts @@ -0,0 +1,13 @@ +import { intl } from "./intl" +import { isLocale } from "./locales" + +// The renderer follows main's language. A change reloads the window, and the +// reloaded page builds its intl in the new language from what preload reports. +export function followMainLocale( + reload: () => void = () => window.location.reload() +): void { + document.documentElement.lang = intl.locale + window.surfsense?.locale?.onChange((next) => { + if (isLocale(next) && next !== intl.locale) reload() + }) +} diff --git a/surfsense_local/frontend/src/i18n/locales.ts b/surfsense_local/frontend/src/i18n/locales.ts new file mode 100644 index 0000000000..8e402d1b1e --- /dev/null +++ b/surfsense_local/frontend/src/i18n/locales.ts @@ -0,0 +1,10 @@ +// The languages the app ships; one catalog each in translations/. +// Mirrored in electron/src/main/i18n/locales.ts. +export const LOCALES = ["en", "ja", "de"] as const +export type Locale = (typeof LOCALES)[number] +export type LocalePreference = Locale | "system" +export const BASE_LOCALE: Locale = "en" + +export function isLocale(value: string): value is Locale { + return (LOCALES as readonly string[]).includes(value) +} diff --git a/surfsense_local/frontend/src/i18n/message-ids.d.ts b/surfsense_local/frontend/src/i18n/message-ids.d.ts new file mode 100644 index 0000000000..61bc3922c7 --- /dev/null +++ b/surfsense_local/frontend/src/i18n/message-ids.d.ts @@ -0,0 +1,15 @@ +import type en from "../../translations/en.json" + +import type { Locale } from "./locales" + +// FormatJS's own typing hook: a message id outside en.json is a type error. +declare global { + namespace FormatjsIntl { + interface Message { + ids: keyof typeof en + } + interface IntlConfig { + locale: Locale + } + } +} diff --git a/surfsense_local/frontend/src/main.tsx b/surfsense_local/frontend/src/main.tsx index 6215617f8c..e2d03e6a76 100644 --- a/surfsense_local/frontend/src/main.tsx +++ b/surfsense_local/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react" import { createRoot } from "react-dom/client" import { QueryClientProvider } from "@tanstack/react-query" +import { RawIntlProvider } from "react-intl" import "./index.css" import App from "./App.tsx" @@ -8,6 +9,8 @@ import { ThemeProvider } from "@/components/theme-provider.tsx" import { Toaster } from "@/components/ui/sonner.tsx" import { TooltipProvider } from "@/components/ui/tooltip.tsx" import { EgressPrompt } from "@/features/egress/egress-prompt.tsx" +import { intl } from "@/i18n/intl.ts" +import { followMainLocale } from "@/i18n/locale.ts" import { queryClient } from "@/lib/query-client.ts" const root = document.getElementById("root") @@ -22,16 +25,20 @@ if (window.surfsense?.platform) { } } +followMainLocale() + createRoot(root).render( - - - - - - - - - + + + + + + + + + + + ) diff --git a/surfsense_local/frontend/translations/de.json b/surfsense_local/frontend/translations/de.json new file mode 100644 index 0000000000..e6d758990c --- /dev/null +++ b/surfsense_local/frontend/translations/de.json @@ -0,0 +1,742 @@ +{ + "app_api_insufficient_space_error": "{message} ({required} GB benötigt, {available} GB verfügbar)", + "app_api_request_failed_error": "Anfrage fehlgeschlagen mit Status {status}", + "app_bootstrap_loader_aria": "SurfSense wird gestartet", + "app_bootstrap_retry_button": "Erneut versuchen", + "app_bootstrap_start_failed_title": "SurfSense konnte nicht starten", + "app_bootstrap_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "app_dialog_close_aria": "Schließen", + "app_dialog_footer_close_button": "Schließen", + "app_relative_time_compact_days_label": "{count} T.", + "app_relative_time_compact_hours_label": "{count} Std.", + "app_relative_time_compact_minutes_label": "{count} Min.", + "app_relative_time_compact_months_label": "{count} Mon.", + "app_relative_time_compact_now_label": "jetzt", + "app_relative_time_compact_weeks_label": "{count} Wo.", + "app_relative_time_compact_years_label": "{count} J.", + "app_relative_time_just_now_label": "gerade eben", + "app_spinner_aria": "Wird geladen", + "chat_chats_dialog_body": "Alle Chats in diesem Workspace.", + "chat_chats_dialog_delete_label": "Chat löschen", + "chat_chats_dialog_empty": "Starte eine Unterhaltung, um sie hier zu sehen", + "chat_chats_dialog_new_chat_button": "Neuer Chat", + "chat_chats_dialog_no_match_empty": "Keine Chats passen zu deiner Suche", + "chat_chats_dialog_rename_label": "Umbenennen", + "chat_chats_dialog_row_actions_aria": "Aktionen für {title}", + "chat_chats_dialog_search_aria": "Chats durchsuchen", + "chat_chats_dialog_search_placeholder": "Chats durchsuchen", + "chat_chats_dialog_title": "Chats", + "chat_chats_dialog_untitled_label": "Neuer Chat", + "chat_citation_panel_aria": "Quellenangabe", + "chat_citation_panel_chunk_label": "Abschnitt #{id}", + "chat_citation_panel_chunk_title": "Abschnitt #{id}", + "chat_citation_panel_cited_label": "Zitierter Abschnitt", + "chat_citation_panel_close_aria": "Quellenangabe schließen", + "chat_citation_panel_earlier_chunks_body": "… {count, plural, one {# früherer Abschnitt wird} other {# frühere Abschnitte werden}} nicht angezeigt", + "chat_citation_panel_later_chunks_body": "… {count, plural, one {# späterer Abschnitt wird} other {# spätere Abschnitte werden}} nicht angezeigt", + "chat_citation_panel_load_error": "Quellenangabe konnte nicht geladen werden", + "chat_citation_panel_loading_status": "Wird geladen …", + "chat_citation_panel_open_file_button": "Datei öffnen", + "chat_composer_add_files_aria": "Quelldateien hinzufügen", + "chat_composer_add_sources_aria": "Quellen hinzufügen", + "chat_composer_add_sources_tooltip": "Quellen hinzufügen", + "chat_composer_disclaimer_body": "SurfSense kann Fehler machen. Überprüfe wichtige Antworten.", + "chat_composer_follow_up_placeholder": "Nachfrage zu dieser Antwort stellen", + "chat_composer_message_aria": "Nachricht", + "chat_composer_provider_offline_body": "Frühere Chats bleiben verfügbar, solange der Anbieter offline ist.", + "chat_composer_provider_offline_placeholder": "Verbinde deinen Modellanbieter erneut, um zu senden", + "chat_composer_send_aria": "Nachricht senden", + "chat_composer_set_up_model_button": "Modell einrichten", + "chat_composer_source_count_label": "{count, plural, one {# Quelle} other {# Quellen}}", + "chat_composer_start_placeholder": "Mach aus deinen Quellen Antworten", + "chat_composer_stop_aria": "Generieren stoppen", + "chat_error_context_too_long": "Diese Unterhaltung ist zu lang für das Kontextfenster des Modells. Starte einen neuen Chat oder wähle ein Modell mit größerem Fenster.", + "chat_error_model_cannot_run": "SurfSense kann dieses Modell nicht ausführen. Wähle ein anderes Modell.", + "chat_error_network": "Der Modellanbieter ist nicht erreichbar – prüfe die URL der Verbindung unter „Modell-Einrichtung“.", + "chat_error_network_llamacpp": "Die lokale Modell-Laufzeit ist nicht erreichbar. Starte SurfSense neu, um sie wieder zu starten.", + "chat_error_provider_auth": "Deine Modellverbindung braucht einen neuen API-Schlüssel.", + "chat_error_provider_not_found": "Das ausgewählte Modell wurde nicht gefunden – wähle unter „Modell-Einrichtung“ ein anderes.", + "chat_error_provider_rate_limited": "Der Modellanbieter begrenzt gerade die Anfragen. Versuch es gleich noch einmal.", + "chat_error_provider_unavailable": "Der Modellanbieter ist vorübergehend nicht verfügbar. Versuch es in Kürze noch einmal.", + "chat_error_timeout": "Das Modell hat zu lange für die Antwort gebraucht. Versuch es noch einmal.", + "chat_error_unknown": "Beim Erstellen der Antwort ist etwas schiefgelaufen. Versuch es noch einmal.", + "chat_failed_reply_model_setup_button": "Modell-Einrichtung", + "chat_failed_reply_retry_button": "Wiederholen", + "chat_inline_citation_aria": "Zitierten Abschnitt {id} anzeigen", + "chat_inline_citation_tooltip": "Quellabschnitt #{id} anzeigen", + "chat_message_copied_aria": "Kopiert", + "chat_message_copied_tooltip": "Kopiert", + "chat_message_copy_aria": "Nachricht kopieren", + "chat_message_copy_tooltip": "Kopieren", + "chat_model_picker_change_tooltip": "Modell wechseln", + "chat_model_picker_installed_title": "Installierte Modelle", + "chat_model_picker_load_error": "Installierte Modelle konnten nicht geladen werden", + "chat_model_picker_loading_status": "Modelle werden geladen …", + "chat_model_picker_manage_label": "Modelle verwalten", + "chat_model_picker_no_match_empty": "Keine passenden Modelle", + "chat_model_picker_none_installed_empty": "Keine installierten Modelle", + "chat_model_picker_search_aria": "Modelle durchsuchen", + "chat_model_picker_search_placeholder": "Modelle durchsuchen", + "chat_model_picker_trigger_aria": "Modell {model}. Modell wechseln.", + "chat_rename_dialog_body": "Wähle einen kurzen Namen, an dem du diese Unterhaltung erkennst.", + "chat_rename_dialog_cancel_button": "Abbrechen", + "chat_rename_dialog_name_aria": "Chat-Name", + "chat_rename_dialog_rename_button": "Umbenennen", + "chat_rename_dialog_saving_status": "Wird gespeichert …", + "chat_rename_dialog_title": "Chat umbenennen", + "chat_rename_dialog_untitled_label": "Neuer Chat", + "chat_runtime_delete_toast": "Chat konnte nicht gelöscht werden", + "chat_runtime_load_messages_toast": "Dieser Chat konnte nicht geladen werden", + "chat_runtime_load_threads_toast": "Deine Chats konnten nicht geladen werden", + "chat_runtime_rename_toast": "Chat konnte nicht umbenannt werden", + "chat_runtime_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "chat_thread_panel_conversation_aria": "Unterhaltung", + "chat_thread_panel_delete_label": "Chat löschen", + "chat_thread_panel_header_aria": "Chat", + "chat_thread_panel_name_aria": "Chat-Name", + "chat_thread_panel_options_aria": "Chat-Optionen für {title}", + "chat_thread_panel_rename_label": "Umbenennen", + "chat_thread_panel_untitled_label": "Neuer Chat", + "chat_viewport_scroll_to_latest_aria": "Zur neuesten Nachricht scrollen", + "dashboard_footer_license_active_status": "Lizenz aktiv", + "dashboard_footer_license_clock_status": "Uhrzeit stimmt nicht", + "dashboard_footer_license_expired_status": "Lizenz abgelaufen", + "dashboard_footer_license_none_status": "Keine Lizenz", + "dashboard_footer_update_check_button": "Nach Updates suchen", + "dashboard_footer_update_checking_status": "Wird geprüft …", + "dashboard_footer_update_current_status": "Auf dem neuesten Stand", + "dashboard_footer_update_downloading_status": "Wird heruntergeladen …", + "dashboard_footer_update_failed_status": "Update-Prüfung fehlgeschlagen", + "dashboard_footer_update_restart_button": "Neu starten und aktualisieren", + "dashboard_plugins_soon_body": "Verbinde externe Tools, um SurfSense zu erweitern. Wir arbeiten noch daran.", + "dashboard_plugins_soon_toast": "Plugins kommen bald", + "dashboard_right_panel_aria": "Workspace-Artefakte", + "dashboard_right_panel_hide_aria": "Rechten Bereich ausblenden", + "dashboard_right_panel_hide_tooltip": "Rechten Bereich ausblenden", + "dashboard_right_panel_show_aria": "Rechten Bereich einblenden", + "dashboard_right_panel_show_tooltip": "Rechten Bereich einblenden", + "dashboard_sidebar_chats_button": "Chats", + "dashboard_sidebar_new_chat_button": "Neuer Chat", + "dashboard_sidebar_plugins_button": "Plugins", + "dashboard_sidebar_plugins_soon_label": "Demnächst", + "dashboard_sources_aria": "Quellen im Workspace", + "dashboard_workspace_error_dismiss_aria": "Workspace-Fehler schließen", + "dashboard_workspace_error_title": "Workspace-Aktion fehlgeschlagen", + "dashboard_workspaces_create_button": "Workspace erstellen", + "dashboard_workspaces_default_name_label": "Mein Workspace", + "dashboard_workspaces_empty": "Keine Workspaces", + "dashboard_workspaces_empty_body": "Ein Workspace hält eine Quellenbibliothek und die zugehörigen Chats zusammen.", + "egress_app_updates_label": "App-Updates", + "egress_app_updates_prompt_body": "Bei der Prüfung fragt SurfSense {host} nach der neuesten Version. Dabei werden nur deine IP-Adresse und deine installierte Version gesendet. Wenn du zustimmst, wird auch die Prüfung beim Start aktiviert. Du kannst sie unter Einstellungen › Netzwerk ausschalten.", + "egress_app_updates_prompt_title": "SurfSense erlauben, nach Updates zu suchen?", + "egress_connection_prompt_body": "Chats über diese Verbindung senden deine Prompts und Auszüge aus deinen Dokumenten an {host}. Du kannst das jederzeit unter Einstellungen › Netzwerk ausschalten.", + "egress_connection_prompt_title": "Senden von Daten an {host} erlauben?", + "egress_destination_allow_aria": "{name} erlauben", + "egress_destination_last_call_label": "Letzter Aufruf: {time}", + "egress_destination_never_called_label": "Letzter Aufruf: nie", + "egress_huggingface_label": "Modellsuche und Downloads", + "egress_huggingface_prompt_body": "Bei der Suche wird deine Eingabe schon während des Tippens an {host} gesendet. Beim Herunterladen wird der Name des gewählten Modells gesendet. Beides überträgt deine IP-Adresse, aber weder deine Chats noch deine Dokumente.", + "egress_huggingface_prompt_title": "Suchen und Herunterladen von Modellen erlauben?", + "egress_network_app_updates_label": "App-Updates", + "egress_network_body": "Alle Ziele, an die SurfSense Daten senden kann. „Aus“ blockiert die Verbindung vollständig. Standardmäßig ist nichts aktiviert.", + "egress_network_load_error": "Netzwerkziele konnten nicht geladen werden.", + "egress_network_title": "Netzwerk", + "egress_prompt_allow_button": "Erlauben", + "egress_prompt_cancel_button": "Abbrechen", + "license_clock_notice_body": "Die Uhr dieses Computers geht hinter dem Zeitpunkt zurück, zu dem SurfSense zuletzt lief. Stelle sie richtig, um deine Lizenz zu nutzen.", + "license_clock_notice_title": "Uhrzeit stimmt nicht", + "license_error_bad_signature": "Diese Lizenzdatei wurde nicht von SurfSense ausgestellt oder wurde verändert.", + "license_error_clock_untrusted": "Die Uhrzeit dieses Computers liegt vor dem Ausstellungszeitpunkt der Lizenz.", + "license_error_file_expired": "Diese Lizenzdatei ist abgelaufen. Lade sie in deinem Konto erneut herunter.", + "license_error_not_a_license_file": "Das ist keine SurfSense-Lizenzdatei.", + "license_error_unsupported_algorithm": "Diese Lizenzdatei verwendet einen Algorithmus, den diese Version nicht prüfen kann.", + "license_expired_notice_body": "Diese Lizenz ist abgelaufen. Verlängere sie in deinem Konto und füge die neue Datei hinzu.", + "license_expired_notice_title": "Abgelaufen", + "license_expiring_notice_body": "Diese Lizenz läuft in {count, plural, one {# Tag} other {# Tagen}} ab.", + "license_expiring_notice_title": "Läuft bald ab", + "license_form_add_button": "Hinzufügen", + "license_form_add_title": "Lizenz hinzufügen", + "license_form_body": "Wähle eine .lic-Datei aus oder füge sie unten ein.", + "license_form_cancel_button": "Abbrechen", + "license_form_choose_file_button": "Lizenzdatei auswählen", + "license_form_file_aria": "Lizenzdatei auswählen", + "license_form_paste_aria": "Lizenzdatei einfügen", + "license_form_paste_label": "Oder füge die Datei ein", + "license_form_replace_title": "Lizenz ersetzen", + "license_plan_title": "Tarif {plan}", + "license_settings_add_button": "Lizenz hinzufügen", + "license_settings_body": "Kostenpflichtige Plugins brauchen eine Lizenzdatei, die an deine E-Mail-Adresse gebunden ist. Sie wird auf diesem Gerät geprüft.", + "license_settings_empty": "Keine Lizenz auf diesem Gerät", + "license_settings_remove_button": "Lizenz entfernen", + "license_settings_replace_button": "Lizenz ersetzen", + "license_settings_title": "Lizenz", + "license_status_active_label": "Aktiv", + "license_status_ended_label": "Abgelaufen am {date}", + "license_status_expires_label": "Läuft ab am {date}", + "license_status_seats_label": "{count, plural, one {# Platz} other {# Plätze}}", + "license_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "menu_view_force_reload_label": "Neu laden erzwingen", + "menu_view_label": "Darstellung", + "menu_view_reload_label": "Neu laden", + "migration_import_file_aria": "Aus SurfSense Cloud importieren", + "migration_import_importing_status": "Wird importiert …", + "migration_import_upload_button": "Hochladen", + "migration_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "models_add_model_local_aria": "Auf diesem Computer", + "models_add_model_local_title": "Auf diesem Computer", + "models_audio_model_languages_label": "{count, plural, one {# Sprache} other {# Sprachen}}", + "models_audio_model_peak_memory_label": "{size} beim Sprechen", + "models_audio_model_voices_label": "{count, plural, one {# Stimme} other {# Stimmen}}", + "models_build_action_download_aria": "{model} {quantization} herunterladen", + "models_build_action_download_button": "Herunterladen", + "models_build_action_in_use_button": "Aktiv", + "models_build_action_use_aria": "{model} {quantization} verwenden", + "models_build_action_use_button": "Verwenden", + "models_connection_dialog_add_body": "Jeder Server, der die OpenAI API unterstützt, etwa vLLM, LM Studio oder OpenRouter.", + "models_connection_dialog_add_title": "Server verbinden", + "models_connection_dialog_edit_body": "Server gelten für alle Modelltypen, eine Änderung hier betrifft also alle.", + "models_connection_dialog_edit_title": "{server} bearbeiten", + "models_connection_form_cancel_button": "Abbrechen", + "models_connection_form_clear_key_label": "Gespeicherten API-Schlüssel entfernen", + "models_connection_form_custom_provider_body": "Jede OpenAI-kompatible URL", + "models_connection_form_custom_provider_label": "Lokaler oder eigener Server", + "models_connection_form_key_label": "API-Schlüssel", + "models_connection_form_key_placeholder": "Optional", + "models_connection_form_key_saved_placeholder": "Leer lassen, um den gespeicherten Schlüssel zu behalten", + "models_connection_form_name_label": "Name", + "models_connection_form_name_placeholder": "Entwicklungs-vLLM", + "models_connection_form_provider_empty": "Kein Anbieter gefunden", + "models_connection_form_provider_label": "Anbieter", + "models_connection_form_provider_needs_account_body": "Braucht deine Kontodaten", + "models_connection_form_provider_needs_url_body": "Gib die URL ein", + "models_connection_form_provider_placeholder": "Anbieter suchen", + "models_connection_form_providers_label": "Anbieter", + "models_connection_form_save_anyway_body": "Speichere nur trotzdem, wenn du diesem Endpunkt vertraust. Du gibst die Modell-IDs dann selbst ein.", + "models_connection_form_save_anyway_button": "Trotzdem speichern", + "models_connection_form_save_button": "Server speichern", + "models_connection_form_save_changes_button": "Änderungen speichern", + "models_connection_form_save_error": "Verbindung konnte nicht gespeichert werden", + "models_connection_form_url_custom_placeholder": "z. B. {url}", + "models_connection_form_url_label": "Basis-URL", + "models_connection_form_url_provider_placeholder": "{provider}-API-URL", + "models_delete_dialog_body": "Dadurch werden das Modell und seine heruntergeladenen Daten endgültig von diesem Computer entfernt.", + "models_delete_dialog_cancel_button": "Abbrechen", + "models_delete_dialog_confirm_button": "Modell löschen", + "models_delete_dialog_deleting_status": "Wird gelöscht…", + "models_delete_dialog_in_use_body": "Das ist dein aktuelles Modell. Wenn du es löschst, musst du ein anderes Modell wählen.", + "models_delete_dialog_title": "{model} löschen?", + "models_disconnect_dialog_cancel_button": "Abbrechen", + "models_disconnect_dialog_clears_both_body": "Dein Chat- und dein Bildmodell werden zurückgesetzt.", + "models_disconnect_dialog_clears_chat_body": "Dein Chat-Modell wird zurückgesetzt.", + "models_disconnect_dialog_clears_image_body": "Dein Bildmodell wird zurückgesetzt.", + "models_disconnect_dialog_clears_none_body": "Kein aktives Modell stammt von {server}.", + "models_disconnect_dialog_confirm_button": "Trennen", + "models_disconnect_dialog_title": "{server} trennen?", + "models_disconnect_error": "Trennen fehlgeschlagen", + "models_disconnect_trigger_aria": "{server} trennen", + "models_disconnect_trigger_button": "Trennen", + "models_download_audio_delete_aria": "{model} löschen", + "models_download_audio_load_error": "Lokale Audiomodelle konnten nicht geladen werden", + "models_download_audio_request_error": "Die Anfrage ist fehlgeschlagen", + "models_download_audio_unsupported_body": "Diese Version hat keine lokale Audio-Laufzeit. Verwende stattdessen oben einen Server.", + "models_download_audio_unsupported_title": "Audiomodelle laufen nicht auf diesem Computer", + "models_download_chat_curated_body": "Modelle, die wir ausgeführt haben, eingeschätzt für diesen Computer.", + "models_download_chat_curated_empty": "Getestete Modelle konnten nicht gelesen werden", + "models_download_chat_curated_empty_body": "Du kannst unten trotzdem auf Hugging Face suchen.", + "models_download_chat_curated_title": "Von SurfSense getestet", + "models_download_chat_load_error": "Lokale Modelle konnten nicht geladen werden", + "models_download_chat_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "models_download_image_load_error": "Lokale Bildmodelle konnten nicht geladen werden", + "models_download_image_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "models_download_image_unsupported_body": "Diese Version hat keine lokale Bild-Laufzeit. Verwende stattdessen oben einen Server.", + "models_download_image_unsupported_title": "Bildmodelle laufen nicht auf diesem Computer", + "models_error_unverified_connection": "Die Modellliste des Endpunkts konnte nicht überprüft werden.", + "models_family_group_aria": "{family}-Modelle", + "models_family_group_other_aria": "Andere Modelle", + "models_family_group_other_label": "Andere", + "models_hardware_apple_gpu_label": "Apple Silicon GPU", + "models_hardware_broken_install_label": "Grafikkarte von der Laufzeit nicht erkannt. Neu installieren, um das zu beheben", + "models_hardware_checking_status": "Dieser Computer wird geprüft", + "models_hardware_cpu_label": "Läuft auf deinem Prozessor", + "models_hardware_gpu_label": "Grafikkarte", + "models_hardware_memory_label": "{size} Arbeitsspeicher", + "models_hardware_summary_body": "Jedes Modell unten ist für diesen Computer eingeschätzt.", + "models_in_use_local_label": "Dieser Computer", + "models_in_use_missing_label": "Nicht auf diesem Computer gefunden", + "models_in_use_summary_aria": "{slot, select, chat {Aktives Chat-Modell} image {Aktives Bildmodell} audio {Aktives Audiomodell} other {Aktives Modell}}", + "models_in_use_summary_empty": "{slot, select, chat {Kein Chat-Modell gewählt} image {Kein Bildmodell gewählt} audio {Kein Audiomodell gewählt} other {Kein Modell gewählt}}", + "models_in_use_summary_label": "Aktiv:", + "models_in_use_unknown_server_label": "Ein Server", + "models_install_cancelled_toast": "Installation abgebrochen. Du kannst es erneut versuchen.", + "models_install_complete_short_status": "Fertig", + "models_install_complete_status": "Fertig", + "models_install_downloaded_status": "{completed} von {total}", + "models_install_downloading_short_status": "Lädt…", + "models_install_downloading_status": "Wird heruntergeladen", + "models_install_error": "Dieses Modell konnte nicht installiert werden", + "models_install_failed_short_status": "Fehler", + "models_install_failed_status": "Installation fehlgeschlagen", + "models_install_preparing_download_status": "Download wird vorbereitet", + "models_install_preparing_short_status": "Vorbereiten…", + "models_install_preparing_status": "Wird vorbereitet", + "models_install_progress_announcement_status": "{label} {percent} %", + "models_install_progress_aria": "Modellinstallation", + "models_install_progress_cancel_button": "Abbrechen", + "models_install_progress_percent_status": "{percent} %", + "models_install_selecting_short_status": "Aktivieren…", + "models_install_selecting_status": "Wird aktiviert", + "models_install_starting_short_status": "Starten…", + "models_install_starting_status": "Wird gestartet", + "models_install_stream_ended_error": "Die Verbindung zur Installation wurde vor dem Abschluss beendet", + "models_install_verifying_short_status": "Prüfen…", + "models_install_verifying_status": "Wird geprüft", + "models_local_group_download_button": "Modell herunterladen", + "models_local_group_empty": "Noch nichts heruntergeladen.", + "models_local_group_request_error": "Die Anfrage ist fehlgeschlagen", + "models_local_group_title": "Dieser Computer", + "models_model_card_builds_aria": "Varianten von {model}", + "models_model_card_delete_aria": "{model} löschen", + "models_model_card_hide_builds_button": "Andere Varianten ausblenden", + "models_model_card_recommended_aria": "Empfohlen für deinen Computer", + "models_model_card_recommended_tooltip": "Empfohlen für deinen Computer", + "models_model_card_runtime_unavailable_error": "Die lokale Laufzeit ist nicht verfügbar.", + "models_model_card_show_builds_button": "{count, plural, one {# weitere Variante} other {# weitere Varianten}}", + "models_model_card_vision_label": "Vision", + "models_model_row_delete_aria": "{model} löschen", + "models_model_row_in_use_button": "Aktiv", + "models_model_row_use_aria": "{model} verwenden", + "models_model_row_use_button": "Verwenden", + "models_search_aria": "Alle Modelle durchsuchen", + "models_search_body": "Der breitere Katalog von Hugging Face, nicht von uns geprüft. Nach Downloads sortiert.", + "models_search_builds_aria": "Varianten in {repo}", + "models_search_builds_body": "Die Größen sind exakt. Ob ein Modell passt, wird geschätzt und vor dem Download geprüft.", + "models_search_builds_empty": "Dieses Repo hat keine Variante, die SurfSense ausführen kann.", + "models_search_builds_error": "Die Varianten dieses Modells konnten nicht aufgelistet werden.", + "models_search_builds_loading_status": "Varianten dieses Modells werden aufgelistet", + "models_search_downloads_label": "{downloads} Downloads", + "models_search_gated_label": "Konto erforderlich", + "models_search_no_results_empty": "Keine Modelle passen zu „{query}“.", + "models_search_placeholder": "Alle Modelle durchsuchen", + "models_search_prompt_empty": "Tippe, um ein Modell auf Hugging Face zu finden.", + "models_search_results_aria": "Suchergebnisse", + "models_search_searching_status": "Suche läuft", + "models_search_title": "Alle Modelle", + "models_search_unreachable_error": "Die Suche braucht Zugriff auf huggingface.co, der ausgeschaltet oder nicht erreichbar ist. Getestete Modelle und alles bereits Installierte funktionieren weiterhin.", + "models_search_vision_label": "Vision", + "models_server_card_aria": "Server verwenden", + "models_server_card_body": "vLLM, LM Studio, OpenRouter oder jede OpenAI-kompatible API.", + "models_server_card_connect_button": "Verbinden", + "models_server_card_title": "Server verwenden", + "models_server_models_capability_unknown_label": "Fähigkeit unbekannt", + "models_server_models_current_in_use_button": "Aktiv", + "models_server_models_edit_aria": "{server} bearbeiten", + "models_server_models_edit_button": "Bearbeiten", + "models_server_models_hide_button": "{slot, select, text_gen {Chat-Modelle ausblenden} image_gen {Bildmodelle ausblenden} image_edit {Bildbearbeitungsmodelle ausblenden} video_gen {Videomodelle ausblenden} audio_gen {Audiomodelle ausblenden} other {Modelle ausblenden}}", + "models_server_models_list_aria": "{slot, select, text_gen {Chat-Modelle auf {server}} image_gen {Bildmodelle auf {server}} image_edit {Bildbearbeitungsmodelle auf {server}} video_gen {Videomodelle auf {server}} audio_gen {Audiomodelle auf {server}} other {Modelle auf {server}}}", + "models_server_models_list_error": "Modelle konnten nicht aufgelistet werden", + "models_server_models_list_in_use_button": "Aktiv", + "models_server_models_loading_aria": "Modelle werden geladen", + "models_server_models_loading_status": "Modelle werden geladen…", + "models_server_models_manual_id_body": "Für ein Modell, das dieser Server nicht auflistet.", + "models_server_models_manual_id_label": "Genaue Modell-ID", + "models_server_models_manual_use_button": "{slot, select, text_gen {Für Chat verwenden} image_gen {Für Bilder verwenden} image_edit {Für Bildbearbeitung verwenden} video_gen {Für Video verwenden} audio_gen {Für Audio verwenden} other {Verwenden}}", + "models_server_models_no_match_empty": "{slot, select, text_gen {Keine passenden Chat-Modelle. Versuche eine andere Suche oder gib unten eine genaue ID ein.} image_gen {Keine passenden Bildmodelle. Versuche eine andere Suche oder gib unten eine genaue ID ein.} image_edit {Keine passenden Bildbearbeitungsmodelle. Versuche eine andere Suche oder gib unten eine genaue ID ein.} video_gen {Keine passenden Videomodelle. Versuche eine andere Suche oder gib unten eine genaue ID ein.} audio_gen {Keine passenden Audiomodelle. Versuche eine andere Suche oder gib unten eine genaue ID ein.} other {Keine passenden Modelle. Versuche eine andere Suche oder gib unten eine genaue ID ein.}}", + "models_server_models_none_listed_empty": "Dieser Server hat keine Modelle aufgelistet. Gib unten eine genaue ID ein.", + "models_server_models_retry_button": "Erneut versuchen", + "models_server_models_search_aria": "Modelle von {server} durchsuchen", + "models_server_models_search_placeholder": "{slot, select, text_gen {Chat-Modelle suchen} image_gen {Bildmodelle suchen} image_edit {Bildbearbeitungsmodelle suchen} video_gen {Videomodelle suchen} audio_gen {Audiomodelle suchen} other {Modelle suchen}}", + "models_server_models_show_button": "{slot, select, text_gen {Chat-Modelle anzeigen} image_gen {Bildmodelle anzeigen} image_edit {Bildbearbeitungsmodelle anzeigen} video_gen {Videomodelle anzeigen} audio_gen {Audiomodelle anzeigen} other {Modelle anzeigen}}", + "models_server_models_type_audio_gen_label": "Audioerzeugung", + "models_server_models_type_image_edit_label": "Bildbearbeitung", + "models_server_models_type_image_gen_label": "Bilderzeugung", + "models_server_models_type_text_gen_label": "Texterzeugung", + "models_server_models_type_video_gen_label": "Videoerzeugung", + "models_server_models_use_aria": "{model} verwenden", + "models_server_models_use_button": "Verwenden", + "models_server_picker_load_error": "Server konnten nicht geladen werden", + "models_server_picker_load_error_title": "Server konnten nicht geladen werden", + "models_try_dialog_cancel_button": "Abbrechen", + "models_try_dialog_chat_confirmed_body": "Chat-Unterstützung ist bestätigt. Der Test sendet einen kurzen Prompt und kann Kosten verursachen.", + "models_try_dialog_chat_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Chat-Unterstützung nicht bestätigt. Der Test sendet einen kurzen Prompt und kann Kosten verursachen.", + "models_try_dialog_image_confirmed_body": "Bildunterstützung ist bestätigt. Der Test führt eine echte Inferenz aus und kann Kosten verursachen.", + "models_try_dialog_image_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Bildunterstützung nicht bestätigt. Er muss /images/generations oder /images unterstützen. Der Test führt eine echte Inferenz aus und kann Kosten verursachen.", + "models_try_dialog_preview_aria": "Testbild von {model}", + "models_try_dialog_request_error": "Die Anfrage ist fehlgeschlagen", + "models_try_dialog_test_chat_button": "Chat testen", + "models_try_dialog_test_image_button": "Bild testen", + "models_try_dialog_title": "{slot, select, text_gen {{model} für Chat verwenden?} image_gen {{model} für Bilder verwenden?} image_edit {{model} für Bildbearbeitung verwenden?} video_gen {{model} für Video verwenden?} audio_gen {{model} für Audio verwenden?} other {{model} verwenden?}}", + "models_try_dialog_unlisted_body": "SurfSense konnte dieses Modell im Live-Katalog nicht bestätigen. Fahre nur fort, wenn die genaue Modell-ID stimmt.", + "models_try_dialog_unlisted_cancel_button": "Abbrechen", + "models_try_dialog_unlisted_confirm_button": "Nicht gelistetes Modell verwenden", + "models_try_dialog_unlisted_title": "Nicht gelistetes Modell verwenden?", + "models_try_dialog_use_tested_button": "Dieses Modell verwenden", + "models_try_dialog_use_untested_button": "Ohne Test verwenden", + "models_your_models_vision_label": "Vision", + "onboarding_audio_step_body": "Damit kann Studio Podcasts erstellen. Führe eines auf diesem Computer aus oder nutze eines von einem Server.", + "onboarding_audio_step_finish_button": "Fertigstellen", + "onboarding_audio_step_no_local_empty": "Audiomodelle können auf diesem Computer nicht laufen. Nutze stattdessen einen Server.", + "onboarding_audio_step_title": "Audiomodell wählen", + "onboarding_chat_step_body": "Antwortet dir im Chat. Führe eines auf diesem Computer aus, damit deine Chats privat bleiben, oder nutze eines von einem Server.", + "onboarding_chat_step_continue_button": "Weiter", + "onboarding_chat_step_no_local_empty": "Kein getestetes Modell kann auf diesem Computer laufen. Nutze stattdessen einen Server.", + "onboarding_chat_step_title": "Chatmodell wählen", + "onboarding_finish_error": "Einrichtung konnte nicht abgeschlossen werden", + "onboarding_finish_no_chat_model_error": "Wähle zuerst ein Chatmodell", + "onboarding_hugging_face_search_hide_button": "Hugging-Face-Suche ausblenden", + "onboarding_hugging_face_search_show_button": "Nicht dabei? Auf Hugging Face suchen", + "onboarding_image_step_body": "Damit kann Studio Bilder erstellen. Führe eines auf diesem Computer aus oder nutze eines von einem Server.", + "onboarding_image_step_continue_button": "Weiter", + "onboarding_image_step_no_local_empty": "Bildmodelle können auf diesem Computer nicht laufen. Nutze stattdessen einen Server.", + "onboarding_image_step_title": "Bildmodell wählen", + "onboarding_model_list_aria": "Modelle für diesen Computer", + "onboarding_model_list_delete_aria": "{name} löschen", + "onboarding_model_list_installed_label": "Auf diesem Computer", + "onboarding_model_list_recommended_label": "Empfohlen", + "onboarding_model_list_vision_label": "Vision", + "onboarding_model_ready_aria": "Modell bereit", + "onboarding_model_ready_local_status": "{name} wird auf diesem Computer verwendet", + "onboarding_model_ready_missing_status": "{name} wird verwendet (nicht auf diesem Computer gefunden)", + "onboarding_model_ready_server_status": "{name} wird über {source} verwendet", + "onboarding_model_step_back_button": "Zurück", + "onboarding_model_step_back_to_models_button": "Zurück zu den Modellen", + "onboarding_model_step_delete_error": "Das Modell konnte nicht gelöscht werden", + "onboarding_model_step_local_aria": "Auf diesem Computer", + "onboarding_model_step_local_title": "Auf diesem Computer", + "onboarding_model_step_optional_label": "Optional", + "onboarding_model_step_skip_button": "Überspringen", + "onboarding_offline_start_body": "Starte es mit diesem Befehl:", + "onboarding_offline_title": "Lokales Backend nicht verfügbar", + "onboarding_progress_aria": "Einrichtungsschritt {step} von {total}", + "onboarding_progress_step_aria": "Schritt {step}", + "onboarding_server_option_aria": "Server nutzen", + "onboarding_server_option_body": "vLLM, LM Studio, OpenRouter oder jede OpenAI-kompatible API.", + "onboarding_server_option_connect_button": "Verbinden", + "onboarding_server_option_connected_body": "Verbunden: {servers}", + "onboarding_server_option_show_button": "Server anzeigen", + "onboarding_server_option_title": "Server nutzen", + "onboarding_server_path_connect_another_button": "Weiteren Server verbinden", + "onboarding_server_path_connect_button": "Server verbinden", + "onboarding_server_path_empty": "Noch keine Server verbunden.", + "onboarding_welcome_body": "Ein privates Recherche-Notizbuch, das komplett auf deinem eigenen Rechner läuft. Deine Dokumente, deine Modellschlüssel, keine Cloud, kein Konto.", + "onboarding_welcome_start_button": "Einrichtung starten", + "onboarding_welcome_title": "Die NotebookLM-Alternative: air-gapped und Open Source", + "settings_appearance_switch_to_dark_aria": "Zum dunklen Design wechseln", + "settings_appearance_switch_to_light_aria": "Zum hellen Design wechseln", + "settings_appearance_switch_to_system_aria": "Zum Systemdesign wechseln", + "settings_appearance_toggle_aria": "Erscheinungsbild", + "settings_audio_models_body": "Das Modell, das Podcasts in Studio vertont. Führe eines auf diesem Computer aus oder nutze eines von einem Server.", + "settings_audio_models_title": "Modelle für Audioerzeugung", + "settings_chat_models_body": "Das Modell, das im Chat antwortet. Führe eines auf diesem Computer aus oder nutze eines von einem Server.", + "settings_chat_models_title": "Modelle für Texterzeugung", + "settings_dialog_body": "Verwalte deine Einstellungen für SurfSense.", + "settings_dialog_title": "Einstellungen", + "settings_general_appearance_body": "Lege fest, wie SurfSense auf diesem Gerät aussieht.", + "settings_general_appearance_title": "Erscheinungsbild", + "settings_general_body": "Lege fest, wie SurfSense auf diesem Gerät aussieht und sich verhält.", + "settings_general_cloud_import_body": "Lade die ZIP-Datei hoch, die du aus SurfSense Cloud exportiert hast.", + "settings_general_cloud_import_info_aria": "Mehr zum Import aus SurfSense Cloud", + "settings_general_cloud_import_link": "SurfSense Cloud öffnen", + "settings_general_cloud_import_title": "Aus SurfSense Cloud importieren", + "settings_general_cloud_import_tooltip": "Deine Workspaces, Ordner und Chats werden übernommen. Dokumente kommen als Text an und werden nach dem Import indexiert. Originaldateien und erstellte Artefakte bleiben in der Cloud.", + "settings_general_language_body": "Wähle die Sprache, die SurfSense auf diesem Gerät verwendet.", + "settings_general_language_system_label": "Wie System", + "settings_general_language_title": "Sprache", + "settings_general_title": "Allgemein", + "settings_image_models_body": "Das Modell, das Bilder in Studio erstellt. Führe eines auf diesem Computer aus oder nutze eines von einem Server.", + "settings_image_models_title": "Modelle für Bilderzeugung", + "settings_models_add_button": "Modell hinzufügen", + "settings_models_add_page_body": "Führe eines auf diesem Computer aus oder nutze eines von einem Server, den du bereits betreibst.", + "settings_models_add_page_title": "{slot, select, audio {Audiomodell hinzufügen} chat {Chatmodell hinzufügen} image {Bildmodell hinzufügen} other {Modell hinzufügen}}", + "settings_models_empty": "{slot, select, audio {Noch kein Audiomodell} chat {Noch kein Chatmodell} image {Noch kein Bildmodell} other {Noch kein Modell}}", + "settings_models_empty_download_body": "Lade eines herunter, um es auf diesem Computer auszuführen, oder nutze eines von einem Server, den du bereits betreibst.", + "settings_models_empty_server_body": "Nutze eines von einem Server, den du bereits betreibst.", + "settings_models_load_error": "Modelleinstellungen konnten nicht geladen werden", + "settings_nav_aria": "Bereiche der Einstellungen", + "settings_nav_audio_label": "Audio", + "settings_nav_chat_label": "Chat", + "settings_nav_general_label": "Allgemein", + "settings_nav_image_label": "Bild", + "settings_nav_license_label": "Lizenz", + "settings_nav_network_label": "Netzwerk", + "settings_nav_title": "Einstellungen", + "sources_action_failed_title": "Quellenaktion fehlgeschlagen", + "sources_add_button": "Hinzufügen", + "sources_add_file_aria": "Quelldateien hochladen", + "sources_add_uploading_status": "Wird hochgeladen …", + "sources_delete_dialog_cancel_button": "Abbrechen", + "sources_delete_dialog_confirm_button": "{count, plural, one {Quelle löschen} other {Quellen löschen}}", + "sources_delete_dialog_named_body": "Dadurch werden {title} und die indexierten Daten endgültig gelöscht.", + "sources_delete_dialog_selected_body": "Dadurch werden die ausgewählten Quellen und ihre indexierten Daten endgültig gelöscht.", + "sources_delete_dialog_title": "{count, plural, one {# Quelle löschen?} other {# Quellen löschen?}}", + "sources_delete_dialog_unnamed_body": "Dadurch werden diese Quelle und ihre indexierten Daten endgültig gelöscht.", + "sources_delete_selected_error": "{count, plural, one {# ausgewählte Quelle konnte nicht gelöscht werden.} other {# ausgewählte Quellen konnten nicht gelöscht werden.}}", + "sources_list_deselect_all_button": "Abwählen", + "sources_list_empty": "Noch keine Quellen", + "sources_list_empty_body": "Dateien und Notizen, die du diesem Workspace hinzufügst, erscheinen hier.", + "sources_list_select_all_button": "Auswählen", + "sources_list_title": "Quellen", + "sources_native_access_unavailable_error": "Direkter Dateizugriff ist nicht verfügbar.", + "sources_open_original_error": "Quelle konnte nicht geöffnet werden", + "sources_reveal_original_error": "Quelle konnte nicht gefunden werden", + "sources_row_actions_aria": "Aktionen für {title}", + "sources_row_cancelled_status": "Abgebrochen", + "sources_row_failed_status": "Einlesen fehlgeschlagen", + "sources_row_menu_cancel_label": "Abbrechen", + "sources_row_menu_delete_label": "Löschen", + "sources_row_menu_deselect_label": "Abwählen", + "sources_row_menu_open_label": "Öffnen", + "sources_row_menu_retry_label": "Erneut versuchen", + "sources_row_menu_reveal_label": "Im Ordner anzeigen", + "sources_row_menu_select_label": "Auswählen", + "sources_row_processing_aria": "{title} wird verarbeitet", + "sources_row_retry_cancelled_aria": "Abgebrochen. {title} erneut versuchen", + "sources_row_retry_cancelled_tooltip": "Abgebrochen. Erneut versuchen.", + "sources_row_retry_failed_aria": "Einlesen fehlgeschlagen. {title} erneut versuchen", + "sources_row_retry_failed_tooltip": "Einlesen fehlgeschlagen. Erneut versuchen.", + "sources_row_select_aria": "{title} auswählen", + "sources_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "sources_upload_added_toast": "{count, plural, one {# Quelle hinzugefügt} other {# Quellen hinzugefügt}}", + "sources_upload_duplicates_body": "Bereits vorhanden: {files}", + "sources_upload_failed_toast": "{count, plural, one {Deine Quelle konnte nicht hinzugefügt werden} other {Deine Quellen konnten nicht hinzugefügt werden}}", + "sources_upload_ingesting_body": "Das Einlesen läuft im Hintergrund.", + "sources_upload_none_added_toast": "Keine neuen Quellen hinzugefügt", + "sources_upload_rejected_body": "Abgelehnt: {files}", + "sources_upload_rejected_file_label": "{filename} ({reason})", + "sources_upload_unsupported_body": "Nicht unterstützt: {files}", + "sources_upload_unsupported_error": "Nicht unterstützter Dateityp: {files}", + "studio_artifact_failed_toast": "{name} fehlgeschlagen", + "studio_artifact_failed_toast_body": "Dieses Artefakt konnte nicht erstellt werden. Versuche es im Tab „Artefakte“ erneut.", + "studio_artifact_list_clear_filter_button": "Filter entfernen", + "studio_artifact_list_empty": "Noch keine erstellten Artefakte", + "studio_artifact_list_empty_body": "In Studio erstellte Artefakte erscheinen hier.", + "studio_artifact_list_filtered_empty": "Keine Artefakte passen zu diesem Filter", + "studio_artifact_list_title": "Artefakte", + "studio_artifact_panel_aria": "Artefakt", + "studio_artifact_panel_close_aria": "Artefakt schließen", + "studio_artifact_panel_download_aria": "Herunterladen", + "studio_artifact_panel_download_preview_aria": "Vorschau herunterladen", + "studio_artifact_panel_load_error": "Artefakt konnte nicht geladen werden", + "studio_artifact_panel_loading_status": "Wird geladen…", + "studio_artifact_panel_title": "Artefakt", + "studio_artifact_ready_toast": "{name} ist fertig", + "studio_artifact_row_actions_aria": "Aktionen für {name}", + "studio_artifact_row_cancel_label": "Abbrechen", + "studio_artifact_row_cancelled_tooltip": "Abgebrochen", + "studio_artifact_row_delete_label": "Löschen", + "studio_artifact_row_failed_tooltip": "Erstellung fehlgeschlagen", + "studio_artifact_row_open_label": "Öffnen", + "studio_artifact_row_processing_aria": "{name} wird erstellt", + "studio_artifact_row_regenerate_label": "Neu erstellen", + "studio_artifact_row_retry_cancelled_aria": "Abgebrochen. {name} erneut versuchen", + "studio_artifact_row_retry_cancelled_tooltip": "Abgebrochen. Versuche es erneut.", + "studio_artifact_row_retry_failed_aria": "Erstellung fehlgeschlagen. {name} erneut versuchen", + "studio_artifact_row_retry_failed_tooltip": "Erstellung fehlgeschlagen. Versuche es erneut.", + "studio_artifact_row_retry_label": "Erneut versuchen", + "studio_composer_brief_status": "Briefing wird vorbereitet…", + "studio_composer_generate_button": "Erstellen", + "studio_composer_prompt_label": "Prompt (optional)", + "studio_composer_prompt_placeholder": "Fokus lenken, z. B. die Risiken hervorheben", + "studio_composer_sources_button": "{count, plural, one {# Quelle} other {# Quellen}}", + "studio_composer_sources_empty": "Füge zuerst eine Quelle hinzu und indexiere sie – nur fertige Dokumente können verwendet werden.", + "studio_composer_sources_label": "Quellen", + "studio_delete_dialog_body": "Dadurch werden {name} und die erstellten Dateien endgültig gelöscht.", + "studio_delete_dialog_cancel_button": "Abbrechen", + "studio_delete_dialog_confirm_button": "Artefakt löschen", + "studio_delete_dialog_title": "{name} löschen?", + "studio_delete_dialog_unnamed_body": "Dadurch werden dieses Artefakt und die erstellten Dateien endgültig gelöscht.", + "studio_delete_dialog_unnamed_title": "Dieses Artefakt löschen?", + "studio_document_viewer_empty": "Dieses Artefakt hat keinen Textinhalt.", + "studio_docx_viewer_error_body": "Dieses Dokument kann hier nicht angezeigt werden. Lade es herunter, um es zu öffnen.", + "studio_docx_viewer_error_title": "Dokument konnte nicht geöffnet werden", + "studio_docx_viewer_load_error": "Dokument konnte nicht geladen werden ({status})", + "studio_docx_viewer_oversize_error": "Dokument ist zu groß für die Vorschau ({size} Byte)", + "studio_docx_viewer_retry_button": "Erneut versuchen", + "studio_docx_viewer_zoom_in_aria": "Vergrößern", + "studio_docx_viewer_zoom_out_aria": "Verkleinern", + "studio_flashcard_surface_reveal_answer_aria": "Antwort aufdecken", + "studio_flashcard_surface_show_question_aria": "Frage anzeigen", + "studio_flashcards_card_answer_label": "Antwort", + "studio_flashcards_card_mark_again_body": "Aktuelle Markierung: Wiederholen", + "studio_flashcards_card_mark_good_body": "Aktuelle Markierung: Gewusst", + "studio_flashcards_card_question_label": "Frage", + "studio_flashcards_card_see_answer_body": "Antwort ansehen", + "studio_flashcards_reset_dialog_body": "Dadurch werden alle Markierungen „Wiederholen“ und „Gewusst“ in diesem Stapel entfernt.", + "studio_flashcards_reset_dialog_cancel_button": "Abbrechen", + "studio_flashcards_reset_dialog_confirm_button": "Fortschritt zurücksetzen", + "studio_flashcards_reset_dialog_title": "Karteikarten-Fortschritt zurücksetzen?", + "studio_flashcards_viewer_again_aria": "Wiederholen, {count, plural, one {# Karte} other {# Karten}}", + "studio_flashcards_viewer_again_button": "Wiederholen", + "studio_flashcards_viewer_good_aria": "Gewusst, {count, plural, one {# Karte} other {# Karten}}", + "studio_flashcards_viewer_good_button": "Gewusst", + "studio_flashcards_viewer_load_error": "Dieser Karteikartenstapel konnte nicht geladen werden", + "studio_flashcards_viewer_mark_error": "Fortschritt konnte nicht gespeichert werden", + "studio_flashcards_viewer_next_aria": "Nächste Karte", + "studio_flashcards_viewer_previous_aria": "Vorherige Karte", + "studio_flashcards_viewer_progress_aria": "Fortschritt im Stapel", + "studio_flashcards_viewer_remaining_status": "{count, plural, one {Noch #} other {Noch #}}", + "studio_flashcards_viewer_reset_aria": "Karteikarten-Fortschritt zurücksetzen", + "studio_flashcards_viewer_reset_error": "Fortschritt konnte nicht zurückgesetzt werden", + "studio_flashcards_viewer_shuffle_aria": "Karten mischen", + "studio_flashcards_viewer_shuffle_button": "Mischen", + "studio_flashcards_viewer_shuffle_error": "Mischen konnte nicht gespeichert werden", + "studio_format_docx_label": "Word", + "studio_format_docx_tooltip": "Word-Dokument mit KI aus deinen Quellen erstellen", + "studio_format_flashcards_label": "Karteikarten", + "studio_format_flashcards_tooltip": "Karteikarten mit KI aus deinen Quellen erstellen", + "studio_format_generic_tooltip": "{format} aus deinen Quellen erstellen", + "studio_format_html_label": "Webseite", + "studio_format_html_tooltip": "Interaktive Webseite mit KI aus deinen Quellen erstellen", + "studio_format_image_label": "Bild", + "studio_format_image_tooltip": "Bild mit KI aus deinen Quellen erstellen", + "studio_format_infographic_label": "Infografik", + "studio_format_infographic_tooltip": "Infografik mit KI aus deinen Quellen erstellen", + "studio_format_mindmap_label": "Mindmap", + "studio_format_mindmap_tooltip": "Mindmap mit KI aus deinen Quellen erstellen", + "studio_format_needs_audio_model_label": "ein Audiomodell", + "studio_format_needs_chat_model_label": "ein Chatmodell", + "studio_format_needs_image_edit_model_label": "ein Bildbearbeitungsmodell", + "studio_format_needs_image_model_label": "ein Bildmodell", + "studio_format_needs_video_model_label": "ein Videomodell", + "studio_format_pdf_label": "PDF", + "studio_format_pdf_tooltip": "PDF mit KI aus deinen Quellen erstellen", + "studio_format_podcast_label": "Podcast", + "studio_format_podcast_tooltip": "Podcast mit KI aus deinen Quellen erstellen", + "studio_format_pptx_label": "Präsentation", + "studio_format_pptx_tooltip": "Präsentation mit KI aus deinen Quellen erstellen", + "studio_format_quiz_label": "Quiz", + "studio_format_quiz_tooltip": "Interaktives Quiz mit KI aus deinen Quellen erstellen", + "studio_format_summary_label": "Zusammenfassung", + "studio_format_summary_tooltip": "Zusammenfassung mit KI aus deinen Quellen erstellen", + "studio_format_unavailable_tooltip": "Benötigt {models}", + "studio_format_xlsx_label": "Tabelle", + "studio_format_xlsx_tooltip": "Tabelle mit KI aus deinen Quellen erstellen", + "studio_html_viewer_error_body": "Diese Seite kann hier nicht angezeigt werden. Lade sie herunter, um sie zu öffnen.", + "studio_html_viewer_error_title": "Seite konnte nicht geöffnet werden", + "studio_html_viewer_retry_button": "Erneut versuchen", + "studio_mindmap_viewer_fit_aria": "Mindmap einpassen", + "studio_mindmap_viewer_tree_aria": "Mindmap", + "studio_panel_error_title": "Studio-Aktion fehlgeschlagen", + "studio_panel_formats_aria": "Studio-Formate", + "studio_pdf_viewer_error_title": "PDF konnte nicht geladen werden", + "studio_pdf_viewer_load_error": "Der Server hat beim Abrufen des PDFs {status} zurückgegeben", + "studio_pdf_viewer_retry_button": "Erneut versuchen", + "studio_pdf_viewer_unknown_error": "PDF konnte nicht geladen werden", + "studio_pdf_viewer_zoom_in_aria": "Vergrößern", + "studio_pdf_viewer_zoom_out_aria": "Verkleinern", + "studio_podcast_brief_add_speaker_button": "Sprecher hinzufügen", + "studio_podcast_brief_language_label": "Sprache", + "studio_podcast_brief_length_label": "Länge", + "studio_podcast_brief_length_long_body": "~15 Min.", + "studio_podcast_brief_length_long_label": "Lang", + "studio_podcast_brief_length_short_body": "~3 Min.", + "studio_podcast_brief_length_short_label": "Kurz", + "studio_podcast_brief_length_standard_body": "~8 Min.", + "studio_podcast_brief_length_standard_label": "Standard", + "studio_podcast_brief_remove_speaker_aria": "Sprecher {number} entfernen", + "studio_podcast_brief_role_cohost_label": "Co-Moderation", + "studio_podcast_brief_role_expert_label": "Fachperson", + "studio_podcast_brief_role_guest_label": "Gast", + "studio_podcast_brief_role_host_label": "Moderation", + "studio_podcast_brief_role_narrator_label": "Erzählstimme", + "studio_podcast_brief_speaker_aria": "Sprecher {number}", + "studio_podcast_brief_speaker_name_aria": "Name", + "studio_podcast_brief_speaker_role_aria": "Rolle", + "studio_podcast_brief_speaker_voice_aria": "Stimme", + "studio_podcast_brief_speakers_label": "Sprecher", + "studio_podcast_brief_style_conversational_label": "Gespräch", + "studio_podcast_brief_style_debate_label": "Debatte", + "studio_podcast_brief_style_interview_label": "Interview", + "studio_podcast_brief_style_label": "Stil", + "studio_podcast_brief_style_monologue_label": "Monolog", + "studio_podcast_brief_style_narrative_label": "Erzählung", + "studio_podcast_viewer_transcript_title": "Transkript", + "studio_pptx_viewer_error_title": "Präsentation konnte nicht geladen werden", + "studio_pptx_viewer_load_error": "Der Server hat beim Abrufen der Präsentation {status} zurückgegeben", + "studio_pptx_viewer_retry_button": "Erneut versuchen", + "studio_pptx_viewer_unknown_error": "Präsentation konnte nicht geladen werden", + "studio_quiz_review_correct_aria": "Richtige Antwort", + "studio_quiz_review_exit_button": "Überprüfung beenden", + "studio_quiz_review_explanation_title": "Erklärung", + "studio_quiz_review_incorrect_aria": "Falsche Antwort", + "studio_quiz_review_next_button": "Weiter", + "studio_quiz_review_position_label": "Frage {number} von {total}", + "studio_quiz_review_previous_button": "Zurück", + "studio_quiz_review_title": "Überprüfung", + "studio_quiz_score_breakdown_aria": "Ergebnisübersicht", + "studio_quiz_score_correct_section_title": "Richtig ({count})", + "studio_quiz_score_correct_tab_label": "{count} richtig", + "studio_quiz_score_missed_section_title": "Falsch ({count})", + "studio_quiz_score_missed_tab_label": "{count} falsch", + "studio_quiz_score_retake_all_label": "Alle Fragen wiederholen", + "studio_quiz_score_retake_button": "Quiz wiederholen", + "studio_quiz_score_retake_missed_label": "Falsche Fragen wiederholen", + "studio_quiz_score_review_button": "Überprüfen", + "studio_quiz_score_review_question_aria": "Frage {number} überprüfen", + "studio_quiz_score_section_empty": "Keine", + "studio_quiz_score_show_correct_aria": "{count, plural, one {# richtige Frage anzeigen} other {# richtige Fragen anzeigen}}", + "studio_quiz_score_show_missed_aria": "{count, plural, one {# falsche Frage anzeigen} other {# falsche Fragen anzeigen}}", + "studio_quiz_score_show_skipped_aria": "{count, plural, one {# übersprungene Frage anzeigen} other {# übersprungene Fragen anzeigen}}", + "studio_quiz_score_skipped_section_title": "Übersprungen ({count})", + "studio_quiz_score_skipped_tab_label": "{count} übersprungen", + "studio_quiz_score_title": "Dein Ergebnis", + "studio_quiz_viewer_answer_error": "Antwort konnte nicht gespeichert werden", + "studio_quiz_viewer_correct_aria": "Richtige Antwort", + "studio_quiz_viewer_finish_button": "Abschließen", + "studio_quiz_viewer_incorrect_aria": "Falsche Antwort", + "studio_quiz_viewer_load_error": "Dieses Quiz konnte nicht geladen werden", + "studio_quiz_viewer_next_button": "Weiter", + "studio_quiz_viewer_options_aria": "Antwortmöglichkeiten für Frage {number}", + "studio_quiz_viewer_progress_aria": "Quiz-Fortschritt", + "studio_quiz_viewer_retake_error": "Quiz konnte nicht neu gestartet werden", + "studio_quiz_viewer_skip_button": "Überspringen", + "studio_quiz_viewer_skip_error": "Frage konnte nicht übersprungen werden", + "studio_quiz_viewer_title": "Beantworte dein Quiz", + "studio_request_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten", + "studio_source_picker_back_button": "Quellen ({count} ausgewählt)", + "studio_source_picker_deselect_all_button": "Alle abwählen", + "studio_source_picker_select_all_button": "Alle auswählen", + "studio_summary_viewer_empty": "Dieses Artefakt hat keinen Textinhalt.", + "studio_type_filter_active_aria": "Artefakte filtern ({count} aktiv)", + "studio_type_filter_aria": "Artefakte filtern", + "studio_type_filter_clear_label": "Filter entfernen", + "studio_type_filter_title": "Nach Typ filtern", + "studio_xlsx_viewer_error_title": "Tabelle konnte nicht geöffnet werden", + "studio_xlsx_viewer_load_error": "Diese Tabelle kann hier nicht angezeigt werden. Lade sie herunter, um sie zu öffnen.", + "studio_xlsx_viewer_oversize_error": "Diese Arbeitsmappe ist zu groß für die Vorschau. Lade sie herunter, um sie zu öffnen.", + "studio_xlsx_viewer_retry_button": "Erneut versuchen", + "studio_xlsx_viewer_sheet_aria": "Arbeitsblatt {name}", + "studio_xlsx_viewer_tabs_aria": "Arbeitsblätter", + "studio_xlsx_viewer_truncated_body": "{count, plural, one {# Zeile wird angezeigt.} other {Die ersten # Zeilen werden angezeigt.}} Lade die Datei herunter, um die ganze Arbeitsmappe zu sehen.", + "studio_xlsx_viewer_unreadable_error": "Diese Arbeitsmappe konnte nicht geöffnet werden. Lade sie herunter, um sie zu öffnen.", + "updates_settings_body": "Kostenlose Updates über GitHub Releases. SurfSense bleibt still, bis du App-Updates unter Netzwerk erlaubst. Damit wird auch die Prüfung beim Start aktiviert.", + "updates_settings_check_button": "Jetzt prüfen", + "updates_settings_check_error": "Suche nach Updates fehlgeschlagen: {message}", + "updates_settings_checking_status": "Wird geprüft …", + "updates_settings_downloading_status": "{version} wird heruntergeladen …", + "updates_settings_ready_status": "SurfSense {version} ist bereit zur Installation", + "updates_settings_restart_button": "Neu starten und aktualisieren", + "updates_settings_title": "App-Updates", + "updates_settings_up_to_date_status": "SurfSense ist auf dem neuesten Stand", + "updates_title_bar_restart_tooltip": "Neu starten, um {version} zu installieren", + "workspaces_create_dialog_body": "Mit eigener Quellenbibliothek und eigenen Chats.", + "workspaces_create_dialog_submit_button": "Erstellen", + "workspaces_create_dialog_title": "Workspace erstellen", + "workspaces_delete_dialog_body": "Dadurch werden die Chats, Dokumente und indexierten Daten endgültig gelöscht.", + "workspaces_delete_dialog_cancel_button": "Abbrechen", + "workspaces_delete_dialog_confirm_button": "Workspace löschen", + "workspaces_delete_dialog_title": "{name} löschen?", + "workspaces_name_dialog_cancel_button": "Abbrechen", + "workspaces_name_dialog_name_aria": "Name des Workspace", + "workspaces_name_dialog_saving_status": "Wird gespeichert …", + "workspaces_rail_aria": "Workspaces", + "workspaces_rail_create_aria": "Workspace erstellen", + "workspaces_rail_create_tooltip": "Workspace erstellen", + "workspaces_rail_delete_label": "Löschen", + "workspaces_rail_rename_label": "Umbenennen", + "workspaces_rail_settings_aria": "Einstellungen öffnen", + "workspaces_rail_settings_tooltip": "Einstellungen", + "workspaces_rename_dialog_body": "Wähle einen Namen, der diesen Recherchekontext beschreibt.", + "workspaces_rename_dialog_submit_button": "Umbenennen", + "workspaces_rename_dialog_title": "Workspace umbenennen", + "workspaces_unexpected_error": "Ein unerwarteter Fehler ist aufgetreten" +} diff --git a/surfsense_local/frontend/translations/en.json b/surfsense_local/frontend/translations/en.json new file mode 100644 index 0000000000..3bd7e64672 --- /dev/null +++ b/surfsense_local/frontend/translations/en.json @@ -0,0 +1,742 @@ +{ + "app_api_insufficient_space_error": "{message} ({required} GB required, {available} GB available)", + "app_api_request_failed_error": "Request failed with status {status}", + "app_bootstrap_loader_aria": "Starting SurfSense", + "app_bootstrap_retry_button": "Retry", + "app_bootstrap_start_failed_title": "SurfSense could not start", + "app_bootstrap_unexpected_error": "An unexpected error occurred", + "app_dialog_close_aria": "Close", + "app_dialog_footer_close_button": "Close", + "app_relative_time_compact_days_label": "{count}d", + "app_relative_time_compact_hours_label": "{count}h", + "app_relative_time_compact_minutes_label": "{count}m", + "app_relative_time_compact_months_label": "{count}mo", + "app_relative_time_compact_now_label": "now", + "app_relative_time_compact_weeks_label": "{count}w", + "app_relative_time_compact_years_label": "{count}y", + "app_relative_time_just_now_label": "just now", + "app_spinner_aria": "Loading", + "chat_chats_dialog_body": "Every chat in this workspace.", + "chat_chats_dialog_delete_label": "Delete chat", + "chat_chats_dialog_empty": "Start a conversation to see it here", + "chat_chats_dialog_new_chat_button": "New chat", + "chat_chats_dialog_no_match_empty": "No chats match your search", + "chat_chats_dialog_rename_label": "Rename", + "chat_chats_dialog_row_actions_aria": "Actions for {title}", + "chat_chats_dialog_search_aria": "Search chats", + "chat_chats_dialog_search_placeholder": "Search chats", + "chat_chats_dialog_title": "Chats", + "chat_chats_dialog_untitled_label": "New chat", + "chat_citation_panel_aria": "Citation", + "chat_citation_panel_chunk_label": "Chunk #{id}", + "chat_citation_panel_chunk_title": "Chunk #{id}", + "chat_citation_panel_cited_label": "Cited chunk", + "chat_citation_panel_close_aria": "Close citation", + "chat_citation_panel_earlier_chunks_body": "… {count, plural, one {# earlier chunk} other {# earlier chunks}} not shown", + "chat_citation_panel_later_chunks_body": "… {count, plural, one {# later chunk} other {# later chunks}} not shown", + "chat_citation_panel_load_error": "Failed to load citation", + "chat_citation_panel_loading_status": "Loading…", + "chat_citation_panel_open_file_button": "Open file", + "chat_composer_add_files_aria": "Add source files", + "chat_composer_add_sources_aria": "Add sources", + "chat_composer_add_sources_tooltip": "Add sources", + "chat_composer_disclaimer_body": "SurfSense can make mistakes. Check important answers.", + "chat_composer_follow_up_placeholder": "Follow up on this answer", + "chat_composer_message_aria": "Message", + "chat_composer_provider_offline_body": "Historical chats remain available while the provider is offline.", + "chat_composer_provider_offline_placeholder": "Reconnect your model provider to send", + "chat_composer_send_aria": "Send message", + "chat_composer_set_up_model_button": "Set up model", + "chat_composer_source_count_label": "{count, plural, one {# source} other {# sources}}", + "chat_composer_start_placeholder": "Turn your sources into answers", + "chat_composer_stop_aria": "Stop generating", + "chat_error_context_too_long": "This conversation is too long for the model's context window. Start a new chat or pick a model with a larger window.", + "chat_error_model_cannot_run": "SurfSense cannot run this model. Pick another model.", + "chat_error_network": "Couldn't reach the model provider — check the connection's URL in Model setup.", + "chat_error_network_llamacpp": "Couldn't reach the local model runtime. Restart SurfSense to start it again.", + "chat_error_provider_auth": "Your model connection needs a new API key.", + "chat_error_provider_not_found": "The selected model couldn't be found — pick another in Model setup.", + "chat_error_provider_rate_limited": "The model provider is rate-limiting requests right now. Try again in a moment.", + "chat_error_provider_unavailable": "The model provider is temporarily unavailable. Try again shortly.", + "chat_error_timeout": "The model took too long to respond. Try again.", + "chat_error_unknown": "Something went wrong generating a reply. Try again.", + "chat_failed_reply_model_setup_button": "Model setup", + "chat_failed_reply_retry_button": "Retry", + "chat_inline_citation_aria": "View cited chunk {id}", + "chat_inline_citation_tooltip": "View source chunk #{id}", + "chat_message_copied_aria": "Copied", + "chat_message_copied_tooltip": "Copied", + "chat_message_copy_aria": "Copy message", + "chat_message_copy_tooltip": "Copy", + "chat_model_picker_change_tooltip": "Change model", + "chat_model_picker_installed_title": "Installed models", + "chat_model_picker_load_error": "Could not load installed models", + "chat_model_picker_loading_status": "Loading models…", + "chat_model_picker_manage_label": "Manage models", + "chat_model_picker_no_match_empty": "No matching models", + "chat_model_picker_none_installed_empty": "No installed models", + "chat_model_picker_search_aria": "Search models", + "chat_model_picker_search_placeholder": "Search models", + "chat_model_picker_trigger_aria": "Model {model}. Change model.", + "chat_rename_dialog_body": "Choose a short name that identifies this conversation.", + "chat_rename_dialog_cancel_button": "Cancel", + "chat_rename_dialog_name_aria": "Chat name", + "chat_rename_dialog_rename_button": "Rename", + "chat_rename_dialog_saving_status": "Saving...", + "chat_rename_dialog_title": "Rename chat", + "chat_rename_dialog_untitled_label": "New chat", + "chat_runtime_delete_toast": "Couldn’t delete chat", + "chat_runtime_load_messages_toast": "Couldn’t load this chat", + "chat_runtime_load_threads_toast": "Couldn’t load your chats", + "chat_runtime_rename_toast": "Couldn’t rename chat", + "chat_runtime_unexpected_error": "An unexpected error occurred", + "chat_thread_panel_conversation_aria": "Conversation", + "chat_thread_panel_delete_label": "Delete chat", + "chat_thread_panel_header_aria": "Chat", + "chat_thread_panel_name_aria": "Chat name", + "chat_thread_panel_options_aria": "Chat options for {title}", + "chat_thread_panel_rename_label": "Rename", + "chat_thread_panel_untitled_label": "New chat", + "chat_viewport_scroll_to_latest_aria": "Scroll to latest message", + "dashboard_footer_license_active_status": "License active", + "dashboard_footer_license_clock_status": "Clock is off", + "dashboard_footer_license_expired_status": "License expired", + "dashboard_footer_license_none_status": "No license", + "dashboard_footer_update_check_button": "Check for updates", + "dashboard_footer_update_checking_status": "Checking…", + "dashboard_footer_update_current_status": "Up to date", + "dashboard_footer_update_downloading_status": "Downloading…", + "dashboard_footer_update_failed_status": "Update check failed", + "dashboard_footer_update_restart_button": "Restart to update", + "dashboard_plugins_soon_body": "Connect external tools to extend what SurfSense can do. We're still polishing this.", + "dashboard_plugins_soon_toast": "Plugins are coming soon", + "dashboard_right_panel_aria": "Workspace artifacts", + "dashboard_right_panel_hide_aria": "Hide right panel", + "dashboard_right_panel_hide_tooltip": "Hide right panel", + "dashboard_right_panel_show_aria": "Show right panel", + "dashboard_right_panel_show_tooltip": "Show right panel", + "dashboard_sidebar_chats_button": "Chats", + "dashboard_sidebar_new_chat_button": "New chat", + "dashboard_sidebar_plugins_button": "Plugins", + "dashboard_sidebar_plugins_soon_label": "Coming soon", + "dashboard_sources_aria": "Workspace sources", + "dashboard_workspace_error_dismiss_aria": "Dismiss workspace error", + "dashboard_workspace_error_title": "Workspace action failed", + "dashboard_workspaces_create_button": "Create workspace", + "dashboard_workspaces_default_name_label": "My Workspace", + "dashboard_workspaces_empty": "No workspaces", + "dashboard_workspaces_empty_body": "A workspace keeps a source library and its chats together.", + "egress_app_updates_label": "App updates", + "egress_app_updates_prompt_body": "Checking asks {host} for the latest release. SurfSense sends your IP address and the version you are running, nothing else. Allowing also turns on the check at launch, which you can switch off in Settings › Network.", + "egress_app_updates_prompt_title": "Allow SurfSense to check for updates?", + "egress_connection_prompt_body": "Chats using this connection send your prompts and excerpts of your documents to {host}. You can turn this off any time in Settings › Network.", + "egress_connection_prompt_title": "Allow sending data to {host}?", + "egress_destination_allow_aria": "Allow {name}", + "egress_destination_last_call_label": "Last call: {time}", + "egress_destination_never_called_label": "Last call: never", + "egress_huggingface_label": "Model search and downloads", + "egress_huggingface_prompt_body": "Searching sends what you type to {host} as you type it. Downloading sends the name of the model you chose. Both send your IP address, and neither sends your chats or your documents.", + "egress_huggingface_prompt_title": "Allow searching and downloading models?", + "egress_network_app_updates_label": "App updates", + "egress_network_body": "Everywhere SurfSense can send data. Off blocks the call outright. Nothing is enabled by default.", + "egress_network_load_error": "Could not load network destinations.", + "egress_network_title": "Network", + "egress_prompt_allow_button": "Allow", + "egress_prompt_cancel_button": "Cancel", + "license_clock_notice_body": "This computer's clock is behind the last time SurfSense ran. Set it right to use your license.", + "license_clock_notice_title": "Clock is off", + "license_error_bad_signature": "This license file was not issued by SurfSense, or it was altered.", + "license_error_clock_untrusted": "This computer's clock is behind the time the license was issued.", + "license_error_file_expired": "This license file has expired; download it again from your account.", + "license_error_not_a_license_file": "This is not a SurfSense license file.", + "license_error_unsupported_algorithm": "This license file uses an algorithm this version cannot check.", + "license_expired_notice_body": "This license has expired. Renew it from your account and add the new file.", + "license_expired_notice_title": "Expired", + "license_expiring_notice_body": "This license runs out in {count, plural, one {# day} other {# days}}.", + "license_expiring_notice_title": "Expiring soon", + "license_form_add_button": "Add", + "license_form_add_title": "Add license", + "license_form_body": "Choose a .lic file or paste it below.", + "license_form_cancel_button": "Cancel", + "license_form_choose_file_button": "Choose license file", + "license_form_file_aria": "Choose license file", + "license_form_paste_aria": "Paste license file", + "license_form_paste_label": "Or paste the file", + "license_form_replace_title": "Replace license", + "license_plan_title": "{plan} plan", + "license_settings_add_button": "Add license", + "license_settings_body": "Paid plugins need a license file tied to your email. It's verified on this device.", + "license_settings_empty": "No license on this device", + "license_settings_remove_button": "Remove license", + "license_settings_replace_button": "Replace license", + "license_settings_title": "License", + "license_status_active_label": "Active", + "license_status_ended_label": "Ended {date}", + "license_status_expires_label": "Expires {date}", + "license_status_seats_label": "{count, plural, one {# seat} other {# seats}}", + "license_unexpected_error": "An unexpected error occurred", + "menu_view_force_reload_label": "Force Reload", + "menu_view_label": "View", + "menu_view_reload_label": "Reload", + "migration_import_file_aria": "Import from SurfSense cloud", + "migration_import_importing_status": "Importing…", + "migration_import_upload_button": "Upload", + "migration_unexpected_error": "An unexpected error occurred", + "models_add_model_local_aria": "On this computer", + "models_add_model_local_title": "On this computer", + "models_audio_model_languages_label": "{count, plural, one {# language} other {# languages}}", + "models_audio_model_peak_memory_label": "{size} while voicing", + "models_audio_model_voices_label": "{count, plural, one {# voice} other {# voices}}", + "models_build_action_download_aria": "Download {model} {quantization}", + "models_build_action_download_button": "Download", + "models_build_action_in_use_button": "In use", + "models_build_action_use_aria": "Use {model} {quantization}", + "models_build_action_use_button": "Use", + "models_connection_dialog_add_body": "Any server that implements the OpenAI API, such as vLLM, LM Studio or OpenRouter.", + "models_connection_dialog_add_title": "Connect a server", + "models_connection_dialog_edit_body": "Servers are shared by every model type, so a change here applies to all of them.", + "models_connection_dialog_edit_title": "Edit {server}", + "models_connection_form_cancel_button": "Cancel", + "models_connection_form_clear_key_label": "Remove saved API key", + "models_connection_form_custom_provider_body": "Any OpenAI-compatible URL", + "models_connection_form_custom_provider_label": "Local or custom server", + "models_connection_form_key_label": "API key", + "models_connection_form_key_placeholder": "Optional", + "models_connection_form_key_saved_placeholder": "Leave blank to keep the saved key", + "models_connection_form_name_label": "Name", + "models_connection_form_name_placeholder": "Engineering vLLM", + "models_connection_form_provider_empty": "No provider found", + "models_connection_form_provider_label": "Provider", + "models_connection_form_provider_needs_account_body": "Needs your account details", + "models_connection_form_provider_needs_url_body": "Enter its URL", + "models_connection_form_provider_placeholder": "Search providers", + "models_connection_form_providers_label": "Providers", + "models_connection_form_save_anyway_body": "Save anyway only if you trust this endpoint. You will enter model IDs manually.", + "models_connection_form_save_anyway_button": "Save anyway", + "models_connection_form_save_button": "Save server", + "models_connection_form_save_changes_button": "Save changes", + "models_connection_form_save_error": "Could not save connection", + "models_connection_form_url_custom_placeholder": "e.g. {url}", + "models_connection_form_url_label": "Base URL", + "models_connection_form_url_provider_placeholder": "{provider} API URL", + "models_delete_dialog_body": "This permanently removes the model and its downloaded data from this computer.", + "models_delete_dialog_cancel_button": "Cancel", + "models_delete_dialog_confirm_button": "Delete model", + "models_delete_dialog_deleting_status": "Deleting…", + "models_delete_dialog_in_use_body": "This is your current model. Deleting it will require you to choose another model.", + "models_delete_dialog_title": "Delete {model}?", + "models_disconnect_dialog_cancel_button": "Cancel", + "models_disconnect_dialog_clears_both_body": "Your Chat and Image models will be cleared.", + "models_disconnect_dialog_clears_chat_body": "Your Chat model will be cleared.", + "models_disconnect_dialog_clears_image_body": "Your Image model will be cleared.", + "models_disconnect_dialog_clears_none_body": "No model in use comes from {server}.", + "models_disconnect_dialog_confirm_button": "Disconnect", + "models_disconnect_dialog_title": "Disconnect {server}?", + "models_disconnect_error": "Could not disconnect", + "models_disconnect_trigger_aria": "Disconnect {server}", + "models_disconnect_trigger_button": "Disconnect", + "models_download_audio_delete_aria": "Delete {model}", + "models_download_audio_load_error": "Could not load local audio models", + "models_download_audio_request_error": "The request failed", + "models_download_audio_unsupported_body": "This build has no local audio runtime. Use a server above instead.", + "models_download_audio_unsupported_title": "Audio models cannot run on this computer", + "models_download_chat_curated_body": "Models we have run, priced against this computer.", + "models_download_chat_curated_empty": "No tested models could be read", + "models_download_chat_curated_empty_body": "You can still search Hugging Face below.", + "models_download_chat_curated_title": "Tested by SurfSense", + "models_download_chat_load_error": "Could not load local models", + "models_download_chat_unexpected_error": "An unexpected error occurred", + "models_download_image_load_error": "Could not load local image models", + "models_download_image_unexpected_error": "An unexpected error occurred", + "models_download_image_unsupported_body": "This build has no local image runtime. Use a server above instead.", + "models_download_image_unsupported_title": "Image models cannot run on this computer", + "models_error_unverified_connection": "The endpoint's model list could not be verified.", + "models_family_group_aria": "{family} models", + "models_family_group_other_aria": "Other models", + "models_family_group_other_label": "Other", + "models_hardware_apple_gpu_label": "Apple Silicon GPU", + "models_hardware_broken_install_label": "Graphics card not detected by the runtime. Reinstall to fix", + "models_hardware_checking_status": "Checking this computer", + "models_hardware_cpu_label": "Runs on your processor", + "models_hardware_gpu_label": "Graphics card", + "models_hardware_memory_label": "{size} memory", + "models_hardware_summary_body": "Every model below is priced against this computer.", + "models_in_use_local_label": "This computer", + "models_in_use_missing_label": "Not found on this computer", + "models_in_use_summary_aria": "{slot, select, chat {chat model in use} image {image model in use} audio {audio model in use} other {model in use}}", + "models_in_use_summary_empty": "{slot, select, chat {No chat model chosen} image {No image model chosen} audio {No audio model chosen} other {No model chosen}}", + "models_in_use_summary_label": "In use:", + "models_in_use_unknown_server_label": "A server", + "models_install_cancelled_toast": "Installation cancelled. You can retry.", + "models_install_complete_short_status": "Done", + "models_install_complete_status": "Done", + "models_install_downloaded_status": "{completed} of {total}", + "models_install_downloading_short_status": "Downloading…", + "models_install_downloading_status": "Downloading", + "models_install_error": "Could not install this model", + "models_install_failed_short_status": "Failed", + "models_install_failed_status": "Install failed", + "models_install_preparing_download_status": "Preparing download", + "models_install_preparing_short_status": "Preparing…", + "models_install_preparing_status": "Preparing", + "models_install_progress_announcement_status": "{label} {percent}%", + "models_install_progress_aria": "Model installation", + "models_install_progress_cancel_button": "Cancel", + "models_install_progress_percent_status": "{percent}%", + "models_install_selecting_short_status": "Selecting…", + "models_install_selecting_status": "Selecting", + "models_install_starting_short_status": "Starting…", + "models_install_starting_status": "Starting", + "models_install_stream_ended_error": "The install stream ended before completion", + "models_install_verifying_short_status": "Verifying…", + "models_install_verifying_status": "Verifying", + "models_local_group_download_button": "Download a model", + "models_local_group_empty": "Nothing downloaded yet.", + "models_local_group_request_error": "The request failed", + "models_local_group_title": "This computer", + "models_model_card_builds_aria": "Builds of {model}", + "models_model_card_delete_aria": "Delete {model}", + "models_model_card_hide_builds_button": "Hide other builds", + "models_model_card_recommended_aria": "Recommended for your computer", + "models_model_card_recommended_tooltip": "Recommended for your computer", + "models_model_card_runtime_unavailable_error": "The local runtime is unavailable.", + "models_model_card_show_builds_button": "{count, plural, one {# other build} other {# other builds}}", + "models_model_card_vision_label": "Vision", + "models_model_row_delete_aria": "Delete {model}", + "models_model_row_in_use_button": "In use", + "models_model_row_use_aria": "Use {model}", + "models_model_row_use_button": "Use", + "models_search_aria": "Search all models", + "models_search_body": "The wider Hugging Face catalog, not reviewed by us. Ordered by downloads.", + "models_search_builds_aria": "Builds in {repo}", + "models_search_builds_body": "Sizes are exact. Fit is estimated and checked before download.", + "models_search_builds_empty": "This repo has no build SurfSense can run.", + "models_search_builds_error": "Could not list this model's builds.", + "models_search_builds_loading_status": "Listing this model's builds", + "models_search_downloads_label": "{downloads} downloads", + "models_search_gated_label": "Needs an account", + "models_search_no_results_empty": "No models match “{query}”.", + "models_search_placeholder": "Search all models", + "models_search_prompt_empty": "Type to find a model on Hugging Face.", + "models_search_results_aria": "Search results", + "models_search_searching_status": "Searching", + "models_search_title": "All models", + "models_search_unreachable_error": "Searching needs access to huggingface.co, which is turned off or unreachable. Tested models and anything already installed still work.", + "models_search_vision_label": "Vision", + "models_server_card_aria": "Use a server", + "models_server_card_body": "vLLM, LM Studio, OpenRouter or any OpenAI-compatible API.", + "models_server_card_connect_button": "Connect", + "models_server_card_title": "Use a server", + "models_server_models_capability_unknown_label": "Capability unknown", + "models_server_models_current_in_use_button": "In use", + "models_server_models_edit_aria": "Edit {server}", + "models_server_models_edit_button": "Edit", + "models_server_models_hide_button": "{slot, select, text_gen {Hide chat models} image_gen {Hide image models} image_edit {Hide image editing models} video_gen {Hide video models} audio_gen {Hide audio models} other {Hide models}}", + "models_server_models_list_aria": "{slot, select, text_gen {chat models on {server}} image_gen {image models on {server}} image_edit {image editing models on {server}} video_gen {video models on {server}} audio_gen {audio models on {server}} other {models on {server}}}", + "models_server_models_list_error": "Could not list models", + "models_server_models_list_in_use_button": "In use", + "models_server_models_loading_aria": "Loading models", + "models_server_models_loading_status": "Loading models…", + "models_server_models_manual_id_body": "For a model this server does not list.", + "models_server_models_manual_id_label": "Exact model ID", + "models_server_models_manual_use_button": "{slot, select, text_gen {Use for chat} image_gen {Use for image} image_edit {Use for image editing} video_gen {Use for video} audio_gen {Use for audio} other {Use}}", + "models_server_models_no_match_empty": "{slot, select, text_gen {No chat models match. Try another search, or type an exact ID below.} image_gen {No image models match. Try another search, or type an exact ID below.} image_edit {No image editing models match. Try another search, or type an exact ID below.} video_gen {No video models match. Try another search, or type an exact ID below.} audio_gen {No audio models match. Try another search, or type an exact ID below.} other {No models match. Try another search, or type an exact ID below.}}", + "models_server_models_none_listed_empty": "This server listed no models. Type an exact ID below.", + "models_server_models_retry_button": "Retry", + "models_server_models_search_aria": "Search models from {server}", + "models_server_models_search_placeholder": "{slot, select, text_gen {Search chat models} image_gen {Search image models} image_edit {Search image editing models} video_gen {Search video models} audio_gen {Search audio models} other {Search models}}", + "models_server_models_show_button": "{slot, select, text_gen {Show chat models} image_gen {Show image models} image_edit {Show image editing models} video_gen {Show video models} audio_gen {Show audio models} other {Show models}}", + "models_server_models_type_audio_gen_label": "Audio generation", + "models_server_models_type_image_edit_label": "Image editing", + "models_server_models_type_image_gen_label": "Image generation", + "models_server_models_type_text_gen_label": "Text generation", + "models_server_models_type_video_gen_label": "Video generation", + "models_server_models_use_aria": "Use {model}", + "models_server_models_use_button": "Use", + "models_server_picker_load_error": "Could not load servers", + "models_server_picker_load_error_title": "Could not load servers", + "models_try_dialog_cancel_button": "Cancel", + "models_try_dialog_chat_confirmed_body": "Chat support is confirmed. Testing sends one short prompt and may cost money.", + "models_try_dialog_chat_unconfirmed_body": "This endpoint does not publish capabilities, so chat support is unconfirmed. Testing sends one short prompt and may cost money.", + "models_try_dialog_image_confirmed_body": "Image support is confirmed. Testing runs real inference and may cost money.", + "models_try_dialog_image_unconfirmed_body": "This endpoint does not publish capabilities, so image support is unconfirmed. It must implement /images/generations or /images. Testing runs real inference and may cost money.", + "models_try_dialog_preview_aria": "Test generated by {model}", + "models_try_dialog_request_error": "The request failed", + "models_try_dialog_test_chat_button": "Test chat", + "models_try_dialog_test_image_button": "Test image", + "models_try_dialog_title": "{slot, select, text_gen {Use {model} for chat?} image_gen {Use {model} for image?} image_edit {Use {model} for image editing?} video_gen {Use {model} for video?} audio_gen {Use {model} for audio?} other {Use {model}?}}", + "models_try_dialog_unlisted_body": "SurfSense could not confirm this model in the live catalogue. Continue only if the exact model ID is correct.", + "models_try_dialog_unlisted_cancel_button": "Cancel", + "models_try_dialog_unlisted_confirm_button": "Use unlisted model", + "models_try_dialog_unlisted_title": "Use an unlisted model?", + "models_try_dialog_use_tested_button": "Use this model", + "models_try_dialog_use_untested_button": "Use without testing", + "models_your_models_vision_label": "Vision", + "onboarding_audio_step_body": "Lets Studio create podcasts. Run one on this computer, or use one from a server.", + "onboarding_audio_step_finish_button": "Finish", + "onboarding_audio_step_no_local_empty": "Audio models cannot run on this computer. Use a server instead.", + "onboarding_audio_step_title": "Choose an audio model", + "onboarding_chat_step_body": "Answers you in chat. Run one on this computer so your chats stay private, or use one from a server.", + "onboarding_chat_step_continue_button": "Continue", + "onboarding_chat_step_no_local_empty": "No tested model can run on this computer. Use a server instead.", + "onboarding_chat_step_title": "Choose a chat model", + "onboarding_finish_error": "Could not finish setup", + "onboarding_finish_no_chat_model_error": "Choose a chat model first", + "onboarding_hugging_face_search_hide_button": "Hide Hugging Face search", + "onboarding_hugging_face_search_show_button": "Not listed? Search Hugging Face", + "onboarding_image_step_body": "Lets Studio create images. Run one on this computer, or use one from a server.", + "onboarding_image_step_continue_button": "Continue", + "onboarding_image_step_no_local_empty": "Image models cannot run on this computer. Use a server instead.", + "onboarding_image_step_title": "Choose an image model", + "onboarding_model_list_aria": "Models for this computer", + "onboarding_model_list_delete_aria": "Delete {name}", + "onboarding_model_list_installed_label": "On this computer", + "onboarding_model_list_recommended_label": "Recommended", + "onboarding_model_list_vision_label": "Vision", + "onboarding_model_ready_aria": "Model ready", + "onboarding_model_ready_local_status": "Using {name} on this computer", + "onboarding_model_ready_missing_status": "Using {name} (not found on this computer)", + "onboarding_model_ready_server_status": "Using {name} via {source}", + "onboarding_model_step_back_button": "Back", + "onboarding_model_step_back_to_models_button": "Back to models", + "onboarding_model_step_delete_error": "Could not delete the model", + "onboarding_model_step_local_aria": "On this computer", + "onboarding_model_step_local_title": "On this computer", + "onboarding_model_step_optional_label": "Optional", + "onboarding_model_step_skip_button": "Skip", + "onboarding_offline_start_body": "Start it with this command:", + "onboarding_offline_title": "Local backend unavailable", + "onboarding_progress_aria": "Onboarding step {step} of {total}", + "onboarding_progress_step_aria": "Step {step}", + "onboarding_server_option_aria": "Use a server", + "onboarding_server_option_body": "vLLM, LM Studio, OpenRouter or any OpenAI-compatible API.", + "onboarding_server_option_connect_button": "Connect", + "onboarding_server_option_connected_body": "Connected: {servers}", + "onboarding_server_option_show_button": "Show servers", + "onboarding_server_option_title": "Use a server", + "onboarding_server_path_connect_another_button": "Connect another server", + "onboarding_server_path_connect_button": "Connect a server", + "onboarding_server_path_empty": "No servers connected yet.", + "onboarding_welcome_body": "A private research notebook that runs entirely on your own machine. Your documents, your model keys, no cloud, no account.", + "onboarding_welcome_start_button": "Start setting up", + "onboarding_welcome_title": "Air-gapped, open source NotebookLM alternative", + "settings_appearance_switch_to_dark_aria": "Switch to dark theme", + "settings_appearance_switch_to_light_aria": "Switch to light theme", + "settings_appearance_switch_to_system_aria": "Switch to system theme", + "settings_appearance_toggle_aria": "Appearance", + "settings_audio_models_body": "The model that voices podcasts in Studio. Run one on this computer, or use one from a server.", + "settings_audio_models_title": "Audio generation models", + "settings_chat_models_body": "The model that answers in chat. Run one on this computer, or use one from a server.", + "settings_chat_models_title": "Text generation models", + "settings_dialog_body": "Manage your SurfSense preferences.", + "settings_dialog_title": "Settings", + "settings_general_appearance_body": "Choose how SurfSense looks on this device.", + "settings_general_appearance_title": "Appearance", + "settings_general_body": "Manage how SurfSense looks and behaves on this device.", + "settings_general_cloud_import_body": "Upload the ZIP you exported from SurfSense cloud.", + "settings_general_cloud_import_info_aria": "More about importing from SurfSense cloud", + "settings_general_cloud_import_link": "Open SurfSense cloud", + "settings_general_cloud_import_title": "Import from SurfSense cloud", + "settings_general_cloud_import_tooltip": "Your workspaces, folders, and chats come with it. Documents come in as text and get indexed after import. Original files and generated artifacts stay in the cloud.", + "settings_general_language_body": "Choose the language SurfSense uses on this device.", + "settings_general_language_system_label": "Match system", + "settings_general_language_title": "Language", + "settings_general_title": "General", + "settings_image_models_body": "The model that makes images in Studio. Run one on this computer, or use one from a server.", + "settings_image_models_title": "Image generation models", + "settings_models_add_button": "Add model", + "settings_models_add_page_body": "Run one on this computer, or use one from a server you already run.", + "settings_models_add_page_title": "{slot, select, audio {Add an audio model} chat {Add a chat model} image {Add an image model} other {Add a model}}", + "settings_models_empty": "{slot, select, audio {No audio model yet} chat {No chat model yet} image {No image model yet} other {No model yet}}", + "settings_models_empty_download_body": "Download one to run on this computer, or use one from a server you already run.", + "settings_models_empty_server_body": "Use one from a server you already run.", + "settings_models_load_error": "Could not load model settings", + "settings_nav_aria": "Settings sections", + "settings_nav_audio_label": "Audio", + "settings_nav_chat_label": "Chat", + "settings_nav_general_label": "General", + "settings_nav_image_label": "Image", + "settings_nav_license_label": "License", + "settings_nav_network_label": "Network", + "settings_nav_title": "Settings", + "sources_action_failed_title": "Source action failed", + "sources_add_button": "Add", + "sources_add_file_aria": "Upload source files", + "sources_add_uploading_status": "Uploading...", + "sources_delete_dialog_cancel_button": "Cancel", + "sources_delete_dialog_confirm_button": "Delete {count, plural, one {source} other {sources}}", + "sources_delete_dialog_named_body": "This permanently deletes {title} and its indexed data.", + "sources_delete_dialog_selected_body": "This permanently deletes the selected sources and their indexed data.", + "sources_delete_dialog_title": "Delete {count, plural, one {# source} other {# sources}}?", + "sources_delete_dialog_unnamed_body": "This permanently deletes this source and its indexed data.", + "sources_delete_selected_error": "{count, plural, one {# selected source could not be deleted.} other {# selected sources could not be deleted.}}", + "sources_list_deselect_all_button": "Deselect all", + "sources_list_empty": "No sources yet", + "sources_list_empty_body": "Files and notes added to this workspace will appear here.", + "sources_list_select_all_button": "Select all", + "sources_list_title": "Sources", + "sources_native_access_unavailable_error": "Native file access is unavailable.", + "sources_open_original_error": "Couldn’t open source", + "sources_reveal_original_error": "Couldn’t locate source", + "sources_row_actions_aria": "Actions for {title}", + "sources_row_cancelled_status": "Cancelled", + "sources_row_failed_status": "Ingestion failed", + "sources_row_menu_cancel_label": "Cancel", + "sources_row_menu_delete_label": "Delete", + "sources_row_menu_deselect_label": "Deselect", + "sources_row_menu_open_label": "Open", + "sources_row_menu_retry_label": "Retry", + "sources_row_menu_reveal_label": "Show in folder", + "sources_row_menu_select_label": "Select", + "sources_row_processing_aria": "Processing {title}", + "sources_row_retry_cancelled_aria": "Cancelled. Retry {title}", + "sources_row_retry_cancelled_tooltip": "Cancelled. Retry again.", + "sources_row_retry_failed_aria": "Ingestion failed. Retry {title}", + "sources_row_retry_failed_tooltip": "Ingestion failed. Retry again.", + "sources_row_select_aria": "Select {title}", + "sources_unexpected_error": "An unexpected error occurred", + "sources_upload_added_toast": "{count, plural, one {# source added} other {# sources added}}", + "sources_upload_duplicates_body": "Already present: {files}", + "sources_upload_failed_toast": "{count, plural, one {Couldn’t add your source} other {Couldn’t add your sources}}", + "sources_upload_ingesting_body": "Ingestion is running in the background.", + "sources_upload_none_added_toast": "No new sources added", + "sources_upload_rejected_body": "Rejected: {files}", + "sources_upload_rejected_file_label": "{filename} ({reason})", + "sources_upload_unsupported_body": "Not supported: {files}", + "sources_upload_unsupported_error": "Unsupported file type: {files}", + "studio_artifact_failed_toast": "{name} failed", + "studio_artifact_failed_toast_body": "This artifact couldn't be generated. Retry it from the artifacts tab.", + "studio_artifact_list_clear_filter_button": "Clear filter", + "studio_artifact_list_empty": "No generated artifacts yet", + "studio_artifact_list_empty_body": "Artifacts generated in Studio will appear here.", + "studio_artifact_list_filtered_empty": "No artifacts match this filter", + "studio_artifact_list_title": "Artifacts", + "studio_artifact_panel_aria": "Artifact", + "studio_artifact_panel_close_aria": "Close artifact", + "studio_artifact_panel_download_aria": "Download", + "studio_artifact_panel_download_preview_aria": "Download preview", + "studio_artifact_panel_load_error": "Failed to load artifact", + "studio_artifact_panel_loading_status": "Loading…", + "studio_artifact_panel_title": "Artifact", + "studio_artifact_ready_toast": "{name} is ready", + "studio_artifact_row_actions_aria": "Actions for {name}", + "studio_artifact_row_cancel_label": "Cancel", + "studio_artifact_row_cancelled_tooltip": "Cancelled", + "studio_artifact_row_delete_label": "Delete", + "studio_artifact_row_failed_tooltip": "Generation failed", + "studio_artifact_row_open_label": "Open", + "studio_artifact_row_processing_aria": "Processing {name}", + "studio_artifact_row_regenerate_label": "Regenerate", + "studio_artifact_row_retry_cancelled_aria": "Cancelled. Retry {name}", + "studio_artifact_row_retry_cancelled_tooltip": "Cancelled. Retry again.", + "studio_artifact_row_retry_failed_aria": "Generation failed. Retry {name}", + "studio_artifact_row_retry_failed_tooltip": "Generation failed. Retry again.", + "studio_artifact_row_retry_label": "Retry", + "studio_composer_brief_status": "Preparing the brief…", + "studio_composer_generate_button": "Generate", + "studio_composer_prompt_label": "Prompt (optional)", + "studio_composer_prompt_placeholder": "Steer the focus, e.g. emphasise the risks", + "studio_composer_sources_button": "{count, plural, one {# source} other {# sources}}", + "studio_composer_sources_empty": "Add and index a source first — only ready documents can be used.", + "studio_composer_sources_label": "Sources", + "studio_delete_dialog_body": "This permanently deletes {name} and its generated files.", + "studio_delete_dialog_cancel_button": "Cancel", + "studio_delete_dialog_confirm_button": "Delete artifact", + "studio_delete_dialog_title": "Delete {name}?", + "studio_delete_dialog_unnamed_body": "This permanently deletes this artifact and its generated files.", + "studio_delete_dialog_unnamed_title": "Delete this artifact?", + "studio_document_viewer_empty": "This artifact has no text body.", + "studio_docx_viewer_error_body": "This document can't be previewed here. Download it to open it.", + "studio_docx_viewer_error_title": "Couldn't open this document", + "studio_docx_viewer_load_error": "Could not load document ({status})", + "studio_docx_viewer_oversize_error": "Document is too large to preview ({size} bytes)", + "studio_docx_viewer_retry_button": "Try again", + "studio_docx_viewer_zoom_in_aria": "Zoom in", + "studio_docx_viewer_zoom_out_aria": "Zoom out", + "studio_flashcard_surface_reveal_answer_aria": "Reveal answer", + "studio_flashcard_surface_show_question_aria": "Show question", + "studio_flashcards_card_answer_label": "Answer", + "studio_flashcards_card_mark_again_body": "Current mark: Needs review", + "studio_flashcards_card_mark_good_body": "Current mark: Got it", + "studio_flashcards_card_question_label": "Question", + "studio_flashcards_card_see_answer_body": "See answer", + "studio_flashcards_reset_dialog_body": "This clears every \"Needs review\" and \"Got it\" mark for this deck.", + "studio_flashcards_reset_dialog_cancel_button": "Cancel", + "studio_flashcards_reset_dialog_confirm_button": "Reset progress", + "studio_flashcards_reset_dialog_title": "Reset flashcard progress?", + "studio_flashcards_viewer_again_aria": "Needs review, {count, plural, one {# card} other {# cards}}", + "studio_flashcards_viewer_again_button": "Needs review", + "studio_flashcards_viewer_good_aria": "Got it, {count, plural, one {# card} other {# cards}}", + "studio_flashcards_viewer_good_button": "Got it", + "studio_flashcards_viewer_load_error": "Failed to load this flashcard deck", + "studio_flashcards_viewer_mark_error": "Progress could not be saved", + "studio_flashcards_viewer_next_aria": "Next card", + "studio_flashcards_viewer_previous_aria": "Previous card", + "studio_flashcards_viewer_progress_aria": "Deck progress", + "studio_flashcards_viewer_remaining_status": "{count, plural, one {# remaining} other {# remaining}}", + "studio_flashcards_viewer_reset_aria": "Reset flashcard progress", + "studio_flashcards_viewer_reset_error": "Progress could not be reset", + "studio_flashcards_viewer_shuffle_aria": "Shuffle cards", + "studio_flashcards_viewer_shuffle_button": "Shuffle", + "studio_flashcards_viewer_shuffle_error": "Shuffle could not be saved", + "studio_format_docx_label": "Word", + "studio_format_docx_tooltip": "Generate an AI Word document based on your sources", + "studio_format_flashcards_label": "Flashcards", + "studio_format_flashcards_tooltip": "Generate AI flashcards based on your sources", + "studio_format_generic_tooltip": "Generate a {format} based on your sources", + "studio_format_html_label": "Web page", + "studio_format_html_tooltip": "Generate an AI interactive web page based on your sources", + "studio_format_image_label": "Image", + "studio_format_image_tooltip": "Generate an AI image based on your sources", + "studio_format_infographic_label": "Infographic", + "studio_format_infographic_tooltip": "Generate an AI infographic based on your sources", + "studio_format_mindmap_label": "Mind map", + "studio_format_mindmap_tooltip": "Generate an AI mind map based on your sources", + "studio_format_needs_audio_model_label": "an audio model", + "studio_format_needs_chat_model_label": "a chat model", + "studio_format_needs_image_edit_model_label": "an image editing model", + "studio_format_needs_image_model_label": "an image model", + "studio_format_needs_video_model_label": "a video model", + "studio_format_pdf_label": "PDF", + "studio_format_pdf_tooltip": "Generate an AI PDF based on your sources", + "studio_format_podcast_label": "Podcast", + "studio_format_podcast_tooltip": "Generate an AI podcast based on your sources", + "studio_format_pptx_label": "Slides", + "studio_format_pptx_tooltip": "Generate an AI slide deck based on your sources", + "studio_format_quiz_label": "Quiz", + "studio_format_quiz_tooltip": "Generate an AI interactive quiz based on your sources", + "studio_format_summary_label": "Summary", + "studio_format_summary_tooltip": "Generate an AI summary based on your sources", + "studio_format_unavailable_tooltip": "Needs {models}", + "studio_format_xlsx_label": "Spreadsheet", + "studio_format_xlsx_tooltip": "Generate an AI spreadsheet based on your sources", + "studio_html_viewer_error_body": "This page can't be previewed here. Download it to open it.", + "studio_html_viewer_error_title": "Couldn't open this page", + "studio_html_viewer_retry_button": "Try again", + "studio_mindmap_viewer_fit_aria": "Fit mind map", + "studio_mindmap_viewer_tree_aria": "Mind map", + "studio_panel_error_title": "Studio action failed", + "studio_panel_formats_aria": "Studio formats", + "studio_pdf_viewer_error_title": "Failed to load PDF", + "studio_pdf_viewer_load_error": "Server returned {status} while retrieving the PDF", + "studio_pdf_viewer_retry_button": "Try again", + "studio_pdf_viewer_unknown_error": "Failed to load PDF", + "studio_pdf_viewer_zoom_in_aria": "Zoom in", + "studio_pdf_viewer_zoom_out_aria": "Zoom out", + "studio_podcast_brief_add_speaker_button": "Add speaker", + "studio_podcast_brief_language_label": "Language", + "studio_podcast_brief_length_label": "Length", + "studio_podcast_brief_length_long_body": "~15 min", + "studio_podcast_brief_length_long_label": "Long", + "studio_podcast_brief_length_short_body": "~3 min", + "studio_podcast_brief_length_short_label": "Short", + "studio_podcast_brief_length_standard_body": "~8 min", + "studio_podcast_brief_length_standard_label": "Standard", + "studio_podcast_brief_remove_speaker_aria": "Remove speaker {number}", + "studio_podcast_brief_role_cohost_label": "Co-host", + "studio_podcast_brief_role_expert_label": "Expert", + "studio_podcast_brief_role_guest_label": "Guest", + "studio_podcast_brief_role_host_label": "Host", + "studio_podcast_brief_role_narrator_label": "Narrator", + "studio_podcast_brief_speaker_aria": "Speaker {number}", + "studio_podcast_brief_speaker_name_aria": "Name", + "studio_podcast_brief_speaker_role_aria": "Role", + "studio_podcast_brief_speaker_voice_aria": "Voice", + "studio_podcast_brief_speakers_label": "Speakers", + "studio_podcast_brief_style_conversational_label": "Conversational", + "studio_podcast_brief_style_debate_label": "Debate", + "studio_podcast_brief_style_interview_label": "Interview", + "studio_podcast_brief_style_label": "Style", + "studio_podcast_brief_style_monologue_label": "Monologue", + "studio_podcast_brief_style_narrative_label": "Narrative", + "studio_podcast_viewer_transcript_title": "Transcript", + "studio_pptx_viewer_error_title": "Failed to load presentation", + "studio_pptx_viewer_load_error": "Server returned {status} while retrieving the presentation", + "studio_pptx_viewer_retry_button": "Try again", + "studio_pptx_viewer_unknown_error": "Failed to load presentation", + "studio_quiz_review_correct_aria": "Correct answer", + "studio_quiz_review_exit_button": "Exit review", + "studio_quiz_review_explanation_title": "Explanation", + "studio_quiz_review_incorrect_aria": "Incorrect answer", + "studio_quiz_review_next_button": "Next", + "studio_quiz_review_position_label": "Question {number} of {total}", + "studio_quiz_review_previous_button": "Previous", + "studio_quiz_review_title": "Review", + "studio_quiz_score_breakdown_aria": "Score breakdown", + "studio_quiz_score_correct_section_title": "Correct ({count})", + "studio_quiz_score_correct_tab_label": "{count} correct", + "studio_quiz_score_missed_section_title": "Missed ({count})", + "studio_quiz_score_missed_tab_label": "{count} missed", + "studio_quiz_score_retake_all_label": "Retake all questions", + "studio_quiz_score_retake_button": "Retake quiz", + "studio_quiz_score_retake_missed_label": "Retake missed questions", + "studio_quiz_score_review_button": "Review", + "studio_quiz_score_review_question_aria": "Review question {number}", + "studio_quiz_score_section_empty": "None", + "studio_quiz_score_show_correct_aria": "Show {count, plural, one {# correct question} other {# correct questions}}", + "studio_quiz_score_show_missed_aria": "Show {count, plural, one {# missed question} other {# missed questions}}", + "studio_quiz_score_show_skipped_aria": "Show {count, plural, one {# skipped question} other {# skipped questions}}", + "studio_quiz_score_skipped_section_title": "Skipped ({count})", + "studio_quiz_score_skipped_tab_label": "{count} skipped", + "studio_quiz_score_title": "Your score", + "studio_quiz_viewer_answer_error": "Answer could not be saved", + "studio_quiz_viewer_correct_aria": "Correct answer", + "studio_quiz_viewer_finish_button": "Finish", + "studio_quiz_viewer_incorrect_aria": "Incorrect answer", + "studio_quiz_viewer_load_error": "Failed to load this quiz", + "studio_quiz_viewer_next_button": "Next", + "studio_quiz_viewer_options_aria": "Question {number} options", + "studio_quiz_viewer_progress_aria": "Quiz progress", + "studio_quiz_viewer_retake_error": "Quiz could not be restarted", + "studio_quiz_viewer_skip_button": "Skip", + "studio_quiz_viewer_skip_error": "Question could not be skipped", + "studio_quiz_viewer_title": "Attempt your quiz", + "studio_request_unexpected_error": "An unexpected error occurred", + "studio_source_picker_back_button": "Sources ({count} selected)", + "studio_source_picker_deselect_all_button": "Deselect all", + "studio_source_picker_select_all_button": "Select all", + "studio_summary_viewer_empty": "This artifact has no text body.", + "studio_type_filter_active_aria": "Filter artifacts ({count} active)", + "studio_type_filter_aria": "Filter artifacts", + "studio_type_filter_clear_label": "Clear filter", + "studio_type_filter_title": "Filter by type", + "studio_xlsx_viewer_error_title": "Couldn't open this spreadsheet", + "studio_xlsx_viewer_load_error": "This spreadsheet can't be previewed here. Download it to open it.", + "studio_xlsx_viewer_oversize_error": "This workbook is too large to preview here. Download it to open it.", + "studio_xlsx_viewer_retry_button": "Try again", + "studio_xlsx_viewer_sheet_aria": "{name} worksheet", + "studio_xlsx_viewer_tabs_aria": "Worksheets", + "studio_xlsx_viewer_truncated_body": "Showing the first {count, plural, one {# row} other {# rows}}. Download the file for the full workbook.", + "studio_xlsx_viewer_unreadable_error": "This workbook could not be opened. Download it to open it.", + "updates_settings_body": "Free updates from GitHub Releases. SurfSense stays silent until you allow App updates under Network, which also enables the launch check.", + "updates_settings_check_button": "Check now", + "updates_settings_check_error": "Could not check for updates: {message}", + "updates_settings_checking_status": "Checking…", + "updates_settings_downloading_status": "Downloading {version}…", + "updates_settings_ready_status": "SurfSense {version} is ready to install", + "updates_settings_restart_button": "Restart to update", + "updates_settings_title": "App updates", + "updates_settings_up_to_date_status": "SurfSense is up to date", + "updates_title_bar_restart_tooltip": "Restart to install {version}", + "workspaces_create_dialog_body": "Keep a separate source library and set of chats.", + "workspaces_create_dialog_submit_button": "Create", + "workspaces_create_dialog_title": "Create workspace", + "workspaces_delete_dialog_body": "This permanently deletes its chats, documents, and indexed data.", + "workspaces_delete_dialog_cancel_button": "Cancel", + "workspaces_delete_dialog_confirm_button": "Delete workspace", + "workspaces_delete_dialog_title": "Delete {name}?", + "workspaces_name_dialog_cancel_button": "Cancel", + "workspaces_name_dialog_name_aria": "Workspace name", + "workspaces_name_dialog_saving_status": "Saving...", + "workspaces_rail_aria": "Workspaces", + "workspaces_rail_create_aria": "Create workspace", + "workspaces_rail_create_tooltip": "Create workspace", + "workspaces_rail_delete_label": "Delete", + "workspaces_rail_rename_label": "Rename", + "workspaces_rail_settings_aria": "Open settings", + "workspaces_rail_settings_tooltip": "Settings", + "workspaces_rename_dialog_body": "Choose a name that identifies this research context.", + "workspaces_rename_dialog_submit_button": "Rename", + "workspaces_rename_dialog_title": "Rename workspace", + "workspaces_unexpected_error": "An unexpected error occurred" +} diff --git a/surfsense_local/frontend/translations/ja.json b/surfsense_local/frontend/translations/ja.json new file mode 100644 index 0000000000..43f050d0fb --- /dev/null +++ b/surfsense_local/frontend/translations/ja.json @@ -0,0 +1,742 @@ +{ + "app_api_insufficient_space_error": "{message}(必要: {required} GB、空き: {available} GB)", + "app_api_request_failed_error": "リクエストが失敗しました(ステータス {status})", + "app_bootstrap_loader_aria": "SurfSenseを起動しています", + "app_bootstrap_retry_button": "再試行", + "app_bootstrap_start_failed_title": "SurfSenseを起動できませんでした", + "app_bootstrap_unexpected_error": "予期しないエラーが発生しました", + "app_dialog_close_aria": "閉じる", + "app_dialog_footer_close_button": "閉じる", + "app_relative_time_compact_days_label": "{count}日", + "app_relative_time_compact_hours_label": "{count}時間", + "app_relative_time_compact_minutes_label": "{count}分", + "app_relative_time_compact_months_label": "{count}か月", + "app_relative_time_compact_now_label": "今", + "app_relative_time_compact_weeks_label": "{count}週", + "app_relative_time_compact_years_label": "{count}年", + "app_relative_time_just_now_label": "たった今", + "app_spinner_aria": "読み込み中", + "chat_chats_dialog_body": "このワークスペースのすべてのチャットです。", + "chat_chats_dialog_delete_label": "チャットを削除", + "chat_chats_dialog_empty": "会話を始めると、ここに表示されます", + "chat_chats_dialog_new_chat_button": "新しいチャット", + "chat_chats_dialog_no_match_empty": "検索に一致するチャットはありません", + "chat_chats_dialog_rename_label": "名前を変更", + "chat_chats_dialog_row_actions_aria": "{title}の操作", + "chat_chats_dialog_search_aria": "チャットを検索", + "chat_chats_dialog_search_placeholder": "チャットを検索", + "chat_chats_dialog_title": "チャット", + "chat_chats_dialog_untitled_label": "新しいチャット", + "chat_citation_panel_aria": "引用", + "chat_citation_panel_chunk_label": "チャンク #{id}", + "chat_citation_panel_chunk_title": "チャンク #{id}", + "chat_citation_panel_cited_label": "引用箇所", + "chat_citation_panel_close_aria": "引用を閉じる", + "chat_citation_panel_earlier_chunks_body": "… 前の{count, plural, other {#件のチャンク}}は表示されていません", + "chat_citation_panel_later_chunks_body": "… 後の{count, plural, other {#件のチャンク}}は表示されていません", + "chat_citation_panel_load_error": "引用を読み込めませんでした", + "chat_citation_panel_loading_status": "読み込み中…", + "chat_citation_panel_open_file_button": "ファイルを開く", + "chat_composer_add_files_aria": "ソースファイルを追加", + "chat_composer_add_sources_aria": "ソースを追加", + "chat_composer_add_sources_tooltip": "ソースを追加", + "chat_composer_disclaimer_body": "SurfSenseは間違えることがあります。重要な回答は確認してください。", + "chat_composer_follow_up_placeholder": "この回答について続けて質問", + "chat_composer_message_aria": "メッセージ", + "chat_composer_provider_offline_body": "プロバイダーがオフラインの間も、過去のチャットは引き続き利用できます。", + "chat_composer_provider_offline_placeholder": "送信するにはモデルプロバイダーに再接続してください", + "chat_composer_send_aria": "メッセージを送信", + "chat_composer_set_up_model_button": "モデルを設定", + "chat_composer_source_count_label": "{count, plural, other {#件のソース}}", + "chat_composer_start_placeholder": "ソースから答えを引き出しましょう", + "chat_composer_stop_aria": "生成を停止", + "chat_error_context_too_long": "この会話はモデルのコンテキストウィンドウに収まらない長さです。新しいチャットを始めるか、より大きなウィンドウのモデルを選んでください。", + "chat_error_model_cannot_run": "SurfSenseではこのモデルを実行できません。別のモデルを選んでください。", + "chat_error_network": "モデルプロバイダーに接続できませんでした。モデル設定で接続先のURLを確認してください。", + "chat_error_network_llamacpp": "ローカルのモデルランタイムに接続できませんでした。SurfSenseを再起動して、ランタイムを起動し直してください。", + "chat_error_provider_auth": "モデルの接続に新しいAPIキーが必要です。", + "chat_error_provider_not_found": "選択したモデルが見つかりませんでした。モデル設定で別のモデルを選んでください。", + "chat_error_provider_rate_limited": "モデルプロバイダーが現在リクエストを制限しています。しばらくしてからもう一度お試しください。", + "chat_error_provider_unavailable": "モデルプロバイダーは一時的に利用できません。しばらくしてからもう一度お試しください。", + "chat_error_timeout": "モデルの応答に時間がかかりすぎました。もう一度お試しください。", + "chat_error_unknown": "返信の生成中に問題が発生しました。もう一度お試しください。", + "chat_failed_reply_model_setup_button": "モデル設定", + "chat_failed_reply_retry_button": "再試行", + "chat_inline_citation_aria": "引用されたチャンク{id}を表示", + "chat_inline_citation_tooltip": "ソースのチャンク #{id}を表示", + "chat_message_copied_aria": "コピーしました", + "chat_message_copied_tooltip": "コピーしました", + "chat_message_copy_aria": "メッセージをコピー", + "chat_message_copy_tooltip": "コピー", + "chat_model_picker_change_tooltip": "モデルを変更", + "chat_model_picker_installed_title": "インストール済みのモデル", + "chat_model_picker_load_error": "インストール済みのモデルを読み込めませんでした", + "chat_model_picker_loading_status": "モデルを読み込み中…", + "chat_model_picker_manage_label": "モデルを管理", + "chat_model_picker_no_match_empty": "一致するモデルはありません", + "chat_model_picker_none_installed_empty": "インストール済みのモデルはありません", + "chat_model_picker_search_aria": "モデルを検索", + "chat_model_picker_search_placeholder": "モデルを検索", + "chat_model_picker_trigger_aria": "モデル: {model}。モデルを変更します。", + "chat_rename_dialog_body": "この会話がわかる短い名前を付けてください。", + "chat_rename_dialog_cancel_button": "キャンセル", + "chat_rename_dialog_name_aria": "チャット名", + "chat_rename_dialog_rename_button": "名前を変更", + "chat_rename_dialog_saving_status": "保存中...", + "chat_rename_dialog_title": "チャットの名前を変更", + "chat_rename_dialog_untitled_label": "新しいチャット", + "chat_runtime_delete_toast": "チャットを削除できませんでした", + "chat_runtime_load_messages_toast": "このチャットを読み込めませんでした", + "chat_runtime_load_threads_toast": "チャットを読み込めませんでした", + "chat_runtime_rename_toast": "チャットの名前を変更できませんでした", + "chat_runtime_unexpected_error": "予期しないエラーが発生しました", + "chat_thread_panel_conversation_aria": "会話", + "chat_thread_panel_delete_label": "チャットを削除", + "chat_thread_panel_header_aria": "チャット", + "chat_thread_panel_name_aria": "チャット名", + "chat_thread_panel_options_aria": "{title}のチャットオプション", + "chat_thread_panel_rename_label": "名前を変更", + "chat_thread_panel_untitled_label": "新しいチャット", + "chat_viewport_scroll_to_latest_aria": "最新のメッセージまでスクロール", + "dashboard_footer_license_active_status": "ライセンス有効", + "dashboard_footer_license_clock_status": "時計がずれています", + "dashboard_footer_license_expired_status": "ライセンス期限切れ", + "dashboard_footer_license_none_status": "ライセンスなし", + "dashboard_footer_update_check_button": "アップデートを確認", + "dashboard_footer_update_checking_status": "確認中…", + "dashboard_footer_update_current_status": "最新です", + "dashboard_footer_update_downloading_status": "ダウンロード中…", + "dashboard_footer_update_failed_status": "アップデートの確認に失敗しました", + "dashboard_footer_update_restart_button": "再起動してアップデート", + "dashboard_plugins_soon_body": "外部ツールを接続して、SurfSenseでできることを広げます。現在仕上げ中です。", + "dashboard_plugins_soon_toast": "プラグインは近日公開予定です", + "dashboard_right_panel_aria": "ワークスペースの生成物", + "dashboard_right_panel_hide_aria": "右パネルを隠す", + "dashboard_right_panel_hide_tooltip": "右パネルを隠す", + "dashboard_right_panel_show_aria": "右パネルを表示", + "dashboard_right_panel_show_tooltip": "右パネルを表示", + "dashboard_sidebar_chats_button": "チャット一覧", + "dashboard_sidebar_new_chat_button": "新しいチャット", + "dashboard_sidebar_plugins_button": "プラグイン", + "dashboard_sidebar_plugins_soon_label": "近日公開", + "dashboard_sources_aria": "ワークスペースのソース", + "dashboard_workspace_error_dismiss_aria": "ワークスペースのエラーを閉じる", + "dashboard_workspace_error_title": "ワークスペースの操作に失敗しました", + "dashboard_workspaces_create_button": "ワークスペースを作成", + "dashboard_workspaces_default_name_label": "マイワークスペース", + "dashboard_workspaces_empty": "ワークスペースがありません", + "dashboard_workspaces_empty_body": "ワークスペースは、ソースライブラリとそのチャットをまとめて管理します。", + "egress_app_updates_label": "アプリのアップデート", + "egress_app_updates_prompt_body": "確認時には{host}に最新リリースを問い合わせます。SurfSenseが送信するのはIPアドレスと実行中のバージョンのみです。許可すると起動時の確認も有効になります。これは設定 › ネットワークでオフにできます。", + "egress_app_updates_prompt_title": "SurfSenseにアップデートの確認を許可しますか?", + "egress_connection_prompt_body": "この接続を使うチャットは、プロンプトとドキュメントの抜粋を{host}に送信します。設定 › ネットワークでいつでもオフにできます。", + "egress_connection_prompt_title": "{host}へのデータ送信を許可しますか?", + "egress_destination_allow_aria": "{name}を許可", + "egress_destination_last_call_label": "最終通信: {time}", + "egress_destination_never_called_label": "最終通信: なし", + "egress_huggingface_label": "モデルの検索とダウンロード", + "egress_huggingface_prompt_body": "検索では、入力した内容が入力と同時に{host}に送信されます。ダウンロードでは、選んだモデルの名前が送信されます。どちらもIPアドレスを送信しますが、チャットやドキュメントは送信しません。", + "egress_huggingface_prompt_title": "モデルの検索とダウンロードを許可しますか?", + "egress_network_app_updates_label": "アプリのアップデート", + "egress_network_body": "SurfSenseがデータを送信しうるすべての送信先です。オフにすると通信を完全にブロックします。初期状態ではすべて無効です。", + "egress_network_load_error": "ネットワークの送信先を読み込めませんでした。", + "egress_network_title": "ネットワーク", + "egress_prompt_allow_button": "許可", + "egress_prompt_cancel_button": "キャンセル", + "license_clock_notice_body": "このコンピューターの時計が、SurfSenseを前回実行した時刻より遅れています。ライセンスを使うには時計を正しく設定してください。", + "license_clock_notice_title": "時計がずれています", + "license_error_bad_signature": "このライセンスファイルはSurfSenseが発行したものではないか、改ざんされています。", + "license_error_clock_untrusted": "このコンピューターの時計が、ライセンスの発行日時より前になっています。", + "license_error_file_expired": "このライセンスファイルは有効期限が切れています。アカウントからもう一度ダウンロードしてください。", + "license_error_not_a_license_file": "これはSurfSenseのライセンスファイルではありません。", + "license_error_unsupported_algorithm": "このライセンスファイルは、このバージョンでは検証できないアルゴリズムを使用しています。", + "license_expired_notice_body": "このライセンスは期限切れです。アカウントから更新し、新しいファイルを追加してください。", + "license_expired_notice_title": "期限切れ", + "license_expiring_notice_body": "このライセンスはあと{count, plural, other {#日}}で期限切れになります。", + "license_expiring_notice_title": "まもなく期限切れ", + "license_form_add_button": "追加", + "license_form_add_title": "ライセンスを追加", + "license_form_body": ".licファイルを選ぶか、下に貼り付けてください。", + "license_form_cancel_button": "キャンセル", + "license_form_choose_file_button": "ライセンスファイルを選択", + "license_form_file_aria": "ライセンスファイルを選択", + "license_form_paste_aria": "ライセンスファイルを貼り付け", + "license_form_paste_label": "またはファイルの内容を貼り付け", + "license_form_replace_title": "ライセンスを置き換え", + "license_plan_title": "{plan}プラン", + "license_settings_add_button": "ライセンスを追加", + "license_settings_body": "有料プラグインには、メールアドレスに紐づいたライセンスファイルが必要です。検証はこのデバイス上で行われます。", + "license_settings_empty": "このデバイスにライセンスがありません", + "license_settings_remove_button": "ライセンスを削除", + "license_settings_replace_button": "ライセンスを置き換え", + "license_settings_title": "ライセンス", + "license_status_active_label": "有効", + "license_status_ended_label": "{date}に終了", + "license_status_expires_label": "{date}に期限切れ", + "license_status_seats_label": "{count, plural, other {#シート}}", + "license_unexpected_error": "予期しないエラーが発生しました", + "menu_view_force_reload_label": "強制再読み込み", + "menu_view_label": "表示", + "menu_view_reload_label": "再読み込み", + "migration_import_file_aria": "SurfSenseクラウドからインポート", + "migration_import_importing_status": "インポート中…", + "migration_import_upload_button": "アップロード", + "migration_unexpected_error": "予期しないエラーが発生しました", + "models_add_model_local_aria": "このコンピューター上", + "models_add_model_local_title": "このコンピューター上", + "models_audio_model_languages_label": "{count, plural, other {#言語}}", + "models_audio_model_peak_memory_label": "音声生成時{size}", + "models_audio_model_voices_label": "{count, plural, other {#種類の音声}}", + "models_build_action_download_aria": "{model} {quantization}をダウンロード", + "models_build_action_download_button": "ダウンロード", + "models_build_action_in_use_button": "使用中", + "models_build_action_use_aria": "{model} {quantization}を使用", + "models_build_action_use_button": "使用", + "models_connection_dialog_add_body": "vLLM、LM Studio、OpenRouterなど、OpenAI APIを実装したサーバーであれば接続できます。", + "models_connection_dialog_add_title": "サーバーを接続", + "models_connection_dialog_edit_body": "サーバーはすべてのモデルの種類で共有されるため、ここでの変更はすべてに適用されます。", + "models_connection_dialog_edit_title": "{server}を編集", + "models_connection_form_cancel_button": "キャンセル", + "models_connection_form_clear_key_label": "保存済みのAPIキーを削除", + "models_connection_form_custom_provider_body": "OpenAI互換の任意のURL", + "models_connection_form_custom_provider_label": "ローカルまたはカスタムサーバー", + "models_connection_form_key_label": "APIキー", + "models_connection_form_key_placeholder": "任意", + "models_connection_form_key_saved_placeholder": "保存済みのキーを使う場合は空欄のまま", + "models_connection_form_name_label": "名前", + "models_connection_form_name_placeholder": "開発チームのvLLM", + "models_connection_form_provider_empty": "プロバイダーが見つかりません", + "models_connection_form_provider_label": "プロバイダー", + "models_connection_form_provider_needs_account_body": "アカウント情報が必要です", + "models_connection_form_provider_needs_url_body": "URLを入力してください", + "models_connection_form_provider_placeholder": "プロバイダーを検索", + "models_connection_form_providers_label": "プロバイダー", + "models_connection_form_save_anyway_body": "このエンドポイントを信頼できる場合のみ、そのまま保存してください。モデルIDは手動で入力することになります。", + "models_connection_form_save_anyway_button": "そのまま保存", + "models_connection_form_save_button": "サーバーを保存", + "models_connection_form_save_changes_button": "変更を保存", + "models_connection_form_save_error": "接続を保存できませんでした", + "models_connection_form_url_custom_placeholder": "例: {url}", + "models_connection_form_url_label": "ベースURL", + "models_connection_form_url_provider_placeholder": "{provider}のAPI URL", + "models_delete_dialog_body": "モデルとダウンロードしたデータをこのコンピューターから完全に削除します。", + "models_delete_dialog_cancel_button": "キャンセル", + "models_delete_dialog_confirm_button": "モデルを削除", + "models_delete_dialog_deleting_status": "削除中…", + "models_delete_dialog_in_use_body": "これは現在使用中のモデルです。削除すると、別のモデルを選ぶ必要があります。", + "models_delete_dialog_title": "{model}を削除しますか?", + "models_disconnect_dialog_cancel_button": "キャンセル", + "models_disconnect_dialog_clears_both_body": "チャットモデルと画像モデルの設定が解除されます。", + "models_disconnect_dialog_clears_chat_body": "チャットモデルの設定が解除されます。", + "models_disconnect_dialog_clears_image_body": "画像モデルの設定が解除されます。", + "models_disconnect_dialog_clears_none_body": "使用中のモデルで{server}のものはありません。", + "models_disconnect_dialog_confirm_button": "接続解除", + "models_disconnect_dialog_title": "{server}の接続を解除しますか?", + "models_disconnect_error": "接続を解除できませんでした", + "models_disconnect_trigger_aria": "{server}の接続を解除", + "models_disconnect_trigger_button": "接続解除", + "models_download_audio_delete_aria": "{model}を削除", + "models_download_audio_load_error": "ローカル音声モデルを読み込めませんでした", + "models_download_audio_request_error": "リクエストに失敗しました", + "models_download_audio_unsupported_body": "このビルドにはローカル音声ランタイムがありません。代わりに上のサーバーを使用してください。", + "models_download_audio_unsupported_title": "このコンピューターでは音声モデルを実行できません", + "models_download_chat_curated_body": "実際に動作を確認したモデルです。このコンピューターに合わせて見積もっています。", + "models_download_chat_curated_empty": "テスト済みモデルを読み込めませんでした", + "models_download_chat_curated_empty_body": "下のHugging Face検索は引き続き使えます。", + "models_download_chat_curated_title": "SurfSenseでテスト済み", + "models_download_chat_load_error": "ローカルモデルを読み込めませんでした", + "models_download_chat_unexpected_error": "予期しないエラーが発生しました", + "models_download_image_load_error": "ローカル画像モデルを読み込めませんでした", + "models_download_image_unexpected_error": "予期しないエラーが発生しました", + "models_download_image_unsupported_body": "このビルドにはローカル画像ランタイムがありません。代わりに上のサーバーを使用してください。", + "models_download_image_unsupported_title": "このコンピューターでは画像モデルを実行できません", + "models_error_unverified_connection": "エンドポイントのモデル一覧を確認できませんでした。", + "models_family_group_aria": "{family}のモデル", + "models_family_group_other_aria": "その他のモデル", + "models_family_group_other_label": "その他", + "models_hardware_apple_gpu_label": "Apple Silicon GPU", + "models_hardware_broken_install_label": "ランタイムがグラフィックカードを検出できません。再インストールで解決します", + "models_hardware_checking_status": "このコンピューターを確認中", + "models_hardware_cpu_label": "プロセッサーで実行", + "models_hardware_gpu_label": "グラフィックカード", + "models_hardware_memory_label": "メモリ{size}", + "models_hardware_summary_body": "以下のモデルはすべて、このコンピューターに合わせて見積もっています。", + "models_in_use_local_label": "このコンピューター", + "models_in_use_missing_label": "このコンピューターに見つかりません", + "models_in_use_summary_aria": "{slot, select, chat {使用中のチャットモデル} image {使用中の画像モデル} audio {使用中の音声モデル} other {使用中のモデル}}", + "models_in_use_summary_empty": "{slot, select, chat {チャットモデルが選択されていません} image {画像モデルが選択されていません} audio {音声モデルが選択されていません} other {モデルが選択されていません}}", + "models_in_use_summary_label": "使用中:", + "models_in_use_unknown_server_label": "サーバー", + "models_install_cancelled_toast": "インストールをキャンセルしました。再試行できます。", + "models_install_complete_short_status": "完了", + "models_install_complete_status": "完了", + "models_install_downloaded_status": "{completed} / {total}", + "models_install_downloading_short_status": "ダウンロード中…", + "models_install_downloading_status": "ダウンロード中", + "models_install_error": "このモデルをインストールできませんでした", + "models_install_failed_short_status": "失敗", + "models_install_failed_status": "インストールに失敗しました", + "models_install_preparing_download_status": "ダウンロードを準備中", + "models_install_preparing_short_status": "準備中…", + "models_install_preparing_status": "準備中", + "models_install_progress_announcement_status": "{label} {percent}%", + "models_install_progress_aria": "モデルのインストール", + "models_install_progress_cancel_button": "キャンセル", + "models_install_progress_percent_status": "{percent}%", + "models_install_selecting_short_status": "設定中…", + "models_install_selecting_status": "設定中", + "models_install_starting_short_status": "開始中…", + "models_install_starting_status": "開始中", + "models_install_stream_ended_error": "インストールの進行状況の接続が完了前に終了しました", + "models_install_verifying_short_status": "検証中…", + "models_install_verifying_status": "検証中", + "models_local_group_download_button": "モデルをダウンロード", + "models_local_group_empty": "まだ何もダウンロードされていません。", + "models_local_group_request_error": "リクエストに失敗しました", + "models_local_group_title": "このコンピューター", + "models_model_card_builds_aria": "{model}のビルド", + "models_model_card_delete_aria": "{model}を削除", + "models_model_card_hide_builds_button": "他のビルドを隠す", + "models_model_card_recommended_aria": "このコンピューターにおすすめ", + "models_model_card_recommended_tooltip": "このコンピューターにおすすめ", + "models_model_card_runtime_unavailable_error": "ローカルランタイムを利用できません。", + "models_model_card_show_builds_button": "{count, plural, other {他のビルド#件}}", + "models_model_card_vision_label": "画像認識", + "models_model_row_delete_aria": "{model}を削除", + "models_model_row_in_use_button": "使用中", + "models_model_row_use_aria": "{model}を使用", + "models_model_row_use_button": "使用", + "models_search_aria": "すべてのモデルを検索", + "models_search_body": "Hugging Faceの幅広いカタログです。SurfSenseでは確認していません。ダウンロード数順に表示します。", + "models_search_builds_aria": "{repo}のビルド", + "models_search_builds_body": "サイズは正確な値です。適合は推定値で、ダウンロード前に確認します。", + "models_search_builds_empty": "このリポジトリにはSurfSenseで実行できるビルドがありません。", + "models_search_builds_error": "このモデルのビルドを一覧表示できませんでした。", + "models_search_builds_loading_status": "このモデルのビルドを取得中", + "models_search_downloads_label": "{downloads}ダウンロード", + "models_search_gated_label": "アカウントが必要", + "models_search_no_results_empty": "「{query}」に一致するモデルはありません。", + "models_search_placeholder": "すべてのモデルを検索", + "models_search_prompt_empty": "入力してHugging Faceのモデルを検索してください。", + "models_search_results_aria": "検索結果", + "models_search_searching_status": "検索中", + "models_search_title": "すべてのモデル", + "models_search_unreachable_error": "検索にはhuggingface.coへのアクセスが必要ですが、オフになっているか接続できません。テスト済みモデルとインストール済みのモデルは引き続き使えます。", + "models_search_vision_label": "画像認識", + "models_server_card_aria": "サーバーを使用", + "models_server_card_body": "vLLM、LM Studio、OpenRouter、またはOpenAI互換のAPI。", + "models_server_card_connect_button": "接続", + "models_server_card_title": "サーバーを使用", + "models_server_models_capability_unknown_label": "機能不明", + "models_server_models_current_in_use_button": "使用中", + "models_server_models_edit_aria": "{server}を編集", + "models_server_models_edit_button": "編集", + "models_server_models_hide_button": "{slot, select, text_gen {チャットモデルを隠す} image_gen {画像モデルを隠す} image_edit {画像編集モデルを隠す} video_gen {動画モデルを隠す} audio_gen {音声モデルを隠す} other {モデルを隠す}}", + "models_server_models_list_aria": "{slot, select, text_gen {{server}のチャットモデル} image_gen {{server}の画像モデル} image_edit {{server}の画像編集モデル} video_gen {{server}の動画モデル} audio_gen {{server}の音声モデル} other {{server}のモデル}}", + "models_server_models_list_error": "モデルを一覧表示できませんでした", + "models_server_models_list_in_use_button": "使用中", + "models_server_models_loading_aria": "モデルを読み込み中", + "models_server_models_loading_status": "モデルを読み込み中…", + "models_server_models_manual_id_body": "このサーバーが一覧に表示しないモデル用です。", + "models_server_models_manual_id_label": "正確なモデルID", + "models_server_models_manual_use_button": "{slot, select, text_gen {チャットに使用} image_gen {画像に使用} image_edit {画像編集に使用} video_gen {動画に使用} audio_gen {音声に使用} other {使用}}", + "models_server_models_no_match_empty": "{slot, select, text_gen {一致するチャットモデルはありません。別の検索語を試すか、下に正確なIDを入力してください。} image_gen {一致する画像モデルはありません。別の検索語を試すか、下に正確なIDを入力してください。} image_edit {一致する画像編集モデルはありません。別の検索語を試すか、下に正確なIDを入力してください。} video_gen {一致する動画モデルはありません。別の検索語を試すか、下に正確なIDを入力してください。} audio_gen {一致する音声モデルはありません。別の検索語を試すか、下に正確なIDを入力してください。} other {一致するモデルはありません。別の検索語を試すか、下に正確なIDを入力してください。}}", + "models_server_models_none_listed_empty": "このサーバーにはモデルがありません。下に正確なIDを入力してください。", + "models_server_models_retry_button": "再試行", + "models_server_models_search_aria": "{server}のモデルを検索", + "models_server_models_search_placeholder": "{slot, select, text_gen {チャットモデルを検索} image_gen {画像モデルを検索} image_edit {画像編集モデルを検索} video_gen {動画モデルを検索} audio_gen {音声モデルを検索} other {モデルを検索}}", + "models_server_models_show_button": "{slot, select, text_gen {チャットモデルを表示} image_gen {画像モデルを表示} image_edit {画像編集モデルを表示} video_gen {動画モデルを表示} audio_gen {音声モデルを表示} other {モデルを表示}}", + "models_server_models_type_audio_gen_label": "音声生成", + "models_server_models_type_image_edit_label": "画像編集", + "models_server_models_type_image_gen_label": "画像生成", + "models_server_models_type_text_gen_label": "テキスト生成", + "models_server_models_type_video_gen_label": "動画生成", + "models_server_models_use_aria": "{model}を使用", + "models_server_models_use_button": "使用", + "models_server_picker_load_error": "サーバーを読み込めませんでした", + "models_server_picker_load_error_title": "サーバーを読み込めませんでした", + "models_try_dialog_cancel_button": "キャンセル", + "models_try_dialog_chat_confirmed_body": "チャットへの対応は確認済みです。テストでは短いプロンプトを1回送信するため、料金が発生する場合があります。", + "models_try_dialog_chat_unconfirmed_body": "このエンドポイントは機能を公開していないため、チャットへの対応は未確認です。テストでは短いプロンプトを1回送信するため、料金が発生する場合があります。", + "models_try_dialog_image_confirmed_body": "画像への対応は確認済みです。テストでは実際に推論を行うため、料金が発生する場合があります。", + "models_try_dialog_image_unconfirmed_body": "このエンドポイントは機能を公開していないため、画像への対応は未確認です。/images/generationsまたは/imagesを実装している必要があります。テストでは実際に推論を行うため、料金が発生する場合があります。", + "models_try_dialog_preview_aria": "{model}が生成したテスト画像", + "models_try_dialog_request_error": "リクエストに失敗しました", + "models_try_dialog_test_chat_button": "チャットをテスト", + "models_try_dialog_test_image_button": "画像をテスト", + "models_try_dialog_title": "{slot, select, text_gen {{model}をチャットに使用しますか?} image_gen {{model}を画像に使用しますか?} image_edit {{model}を画像編集に使用しますか?} video_gen {{model}を動画に使用しますか?} audio_gen {{model}を音声に使用しますか?} other {{model}を使用しますか?}}", + "models_try_dialog_unlisted_body": "SurfSenseはこのモデルを最新のカタログで確認できませんでした。モデルIDが正確な場合のみ続行してください。", + "models_try_dialog_unlisted_cancel_button": "キャンセル", + "models_try_dialog_unlisted_confirm_button": "一覧にないモデルを使用", + "models_try_dialog_unlisted_title": "一覧にないモデルを使用しますか?", + "models_try_dialog_use_tested_button": "このモデルを使用", + "models_try_dialog_use_untested_button": "テストせずに使用", + "models_your_models_vision_label": "画像認識", + "onboarding_audio_step_body": "Studioでポッドキャストを作成できるようにします。このコンピューターで実行するか、サーバー上のモデルを使います。", + "onboarding_audio_step_finish_button": "完了", + "onboarding_audio_step_no_local_empty": "このコンピューターでは音声モデルを実行できません。代わりにサーバーを使ってください。", + "onboarding_audio_step_title": "音声モデルを選択", + "onboarding_chat_step_body": "チャットで回答します。このコンピューターで実行すればチャットはプライベートなまま保たれます。サーバー上のモデルを使うこともできます。", + "onboarding_chat_step_continue_button": "続行", + "onboarding_chat_step_no_local_empty": "このコンピューターで実行できる検証済みのモデルはありません。代わりにサーバーを使ってください。", + "onboarding_chat_step_title": "チャットモデルを選択", + "onboarding_finish_error": "セットアップを完了できませんでした", + "onboarding_finish_no_chat_model_error": "先にチャットモデルを選んでください", + "onboarding_hugging_face_search_hide_button": "Hugging Faceの検索を閉じる", + "onboarding_hugging_face_search_show_button": "見つからない場合はHugging Faceを検索", + "onboarding_image_step_body": "Studioで画像を作成できるようにします。このコンピューターで実行するか、サーバー上のモデルを使います。", + "onboarding_image_step_continue_button": "続行", + "onboarding_image_step_no_local_empty": "このコンピューターでは画像モデルを実行できません。代わりにサーバーを使ってください。", + "onboarding_image_step_title": "画像モデルを選択", + "onboarding_model_list_aria": "このコンピューター向けのモデル", + "onboarding_model_list_delete_aria": "{name}を削除", + "onboarding_model_list_installed_label": "このコンピューター上", + "onboarding_model_list_recommended_label": "おすすめ", + "onboarding_model_list_vision_label": "画像認識", + "onboarding_model_ready_aria": "モデルの準備完了", + "onboarding_model_ready_local_status": "このコンピューターで{name}を使用中", + "onboarding_model_ready_missing_status": "{name}を使用中(このコンピューター上に見つかりません)", + "onboarding_model_ready_server_status": "{source}経由で{name}を使用中", + "onboarding_model_step_back_button": "戻る", + "onboarding_model_step_back_to_models_button": "モデル一覧に戻る", + "onboarding_model_step_delete_error": "モデルを削除できませんでした", + "onboarding_model_step_local_aria": "このコンピューター上", + "onboarding_model_step_local_title": "このコンピューター上", + "onboarding_model_step_optional_label": "任意", + "onboarding_model_step_skip_button": "スキップ", + "onboarding_offline_start_body": "次のコマンドで起動してください:", + "onboarding_offline_title": "ローカルバックエンドを利用できません", + "onboarding_progress_aria": "オンボーディング ステップ{step}/{total}", + "onboarding_progress_step_aria": "ステップ{step}", + "onboarding_server_option_aria": "サーバーを使用", + "onboarding_server_option_body": "vLLM、LM Studio、OpenRouter、またはOpenAI互換の任意のAPI。", + "onboarding_server_option_connect_button": "接続", + "onboarding_server_option_connected_body": "接続済み:{servers}", + "onboarding_server_option_show_button": "サーバーを表示", + "onboarding_server_option_title": "サーバーを使用", + "onboarding_server_path_connect_another_button": "別のサーバーを接続", + "onboarding_server_path_connect_button": "サーバーを接続", + "onboarding_server_path_empty": "接続済みのサーバーはまだありません。", + "onboarding_welcome_body": "完全に自分のマシン上で動作するプライベートなリサーチノートブックです。ドキュメントもモデルのキーも手元に。クラウドもアカウントも不要です。", + "onboarding_welcome_start_button": "セットアップを始める", + "onboarding_welcome_title": "エアギャップ対応、オープンソースのNotebookLMの代替", + "settings_appearance_switch_to_dark_aria": "ダークテーマに切り替え", + "settings_appearance_switch_to_light_aria": "ライトテーマに切り替え", + "settings_appearance_switch_to_system_aria": "システムのテーマに切り替え", + "settings_appearance_toggle_aria": "外観", + "settings_audio_models_body": "Studioでポッドキャストの音声を生成するモデルです。このコンピューターで実行するか、サーバー上のモデルを使います。", + "settings_audio_models_title": "音声生成モデル", + "settings_chat_models_body": "チャットで回答するモデルです。このコンピューターで実行するか、サーバー上のモデルを使います。", + "settings_chat_models_title": "テキスト生成モデル", + "settings_dialog_body": "SurfSenseの環境設定を管理します。", + "settings_dialog_title": "設定", + "settings_general_appearance_body": "このデバイスでのSurfSenseの見た目を選びます。", + "settings_general_appearance_title": "外観", + "settings_general_body": "このデバイスでのSurfSenseの見た目と動作を管理します。", + "settings_general_cloud_import_body": "SurfSenseクラウドからエクスポートしたZIPをアップロードしてください。", + "settings_general_cloud_import_info_aria": "SurfSenseクラウドからのインポートの詳細", + "settings_general_cloud_import_link": "SurfSenseクラウドを開く", + "settings_general_cloud_import_title": "SurfSenseクラウドからインポート", + "settings_general_cloud_import_tooltip": "ワークスペース、フォルダー、チャットが引き継がれます。ドキュメントはテキストとして取り込まれ、インポート後にインデックスされます。元のファイルと生成物はクラウドに残ります。", + "settings_general_language_body": "このデバイスでSurfSenseが使う言語を選びます。", + "settings_general_language_system_label": "システムに合わせる", + "settings_general_language_title": "言語", + "settings_general_title": "一般", + "settings_image_models_body": "Studioで画像を生成するモデルです。このコンピューターで実行するか、サーバー上のモデルを使います。", + "settings_image_models_title": "画像生成モデル", + "settings_models_add_button": "モデルを追加", + "settings_models_add_page_body": "このコンピューターで実行するか、すでに運用しているサーバー上のモデルを使います。", + "settings_models_add_page_title": "{slot, select, audio {音声モデルを追加} chat {チャットモデルを追加} image {画像モデルを追加} other {モデルを追加}}", + "settings_models_empty": "{slot, select, audio {音声モデルはまだありません} chat {チャットモデルはまだありません} image {画像モデルはまだありません} other {モデルはまだありません}}", + "settings_models_empty_download_body": "ダウンロードしてこのコンピューターで実行するか、すでに運用しているサーバー上のモデルを使います。", + "settings_models_empty_server_body": "すでに運用しているサーバー上のモデルを使います。", + "settings_models_load_error": "モデル設定を読み込めませんでした", + "settings_nav_aria": "設定のセクション", + "settings_nav_audio_label": "音声", + "settings_nav_chat_label": "チャット", + "settings_nav_general_label": "一般", + "settings_nav_image_label": "画像", + "settings_nav_license_label": "ライセンス", + "settings_nav_network_label": "ネットワーク", + "settings_nav_title": "設定", + "sources_action_failed_title": "ソースの操作に失敗しました", + "sources_add_button": "追加", + "sources_add_file_aria": "ソースファイルをアップロード", + "sources_add_uploading_status": "アップロード中…", + "sources_delete_dialog_cancel_button": "キャンセル", + "sources_delete_dialog_confirm_button": "{count, plural, other {ソースを削除}}", + "sources_delete_dialog_named_body": "{title}とそのインデックス済みデータを完全に削除します。", + "sources_delete_dialog_selected_body": "選択したソースとそのインデックス済みデータを完全に削除します。", + "sources_delete_dialog_title": "{count, plural, other {#件のソースを削除しますか?}}", + "sources_delete_dialog_unnamed_body": "このソースとそのインデックス済みデータを完全に削除します。", + "sources_delete_selected_error": "{count, plural, other {選択したソースのうち#件を削除できませんでした。}}", + "sources_list_deselect_all_button": "すべて選択解除", + "sources_list_empty": "ソースはまだありません", + "sources_list_empty_body": "このワークスペースに追加したファイルやメモがここに表示されます。", + "sources_list_select_all_button": "すべて選択", + "sources_list_title": "ソース", + "sources_native_access_unavailable_error": "ネイティブのファイルアクセスを利用できません。", + "sources_open_original_error": "ソースを開けませんでした", + "sources_reveal_original_error": "ソースの場所を表示できませんでした", + "sources_row_actions_aria": "{title}の操作", + "sources_row_cancelled_status": "キャンセル済み", + "sources_row_failed_status": "取り込みに失敗しました", + "sources_row_menu_cancel_label": "キャンセル", + "sources_row_menu_delete_label": "削除", + "sources_row_menu_deselect_label": "選択解除", + "sources_row_menu_open_label": "開く", + "sources_row_menu_retry_label": "再試行", + "sources_row_menu_reveal_label": "フォルダーに表示", + "sources_row_menu_select_label": "選択", + "sources_row_processing_aria": "{title}を処理中", + "sources_row_retry_cancelled_aria": "キャンセルされました。{title}を再試行", + "sources_row_retry_cancelled_tooltip": "キャンセルされました。もう一度お試しください。", + "sources_row_retry_failed_aria": "取り込みに失敗しました。{title}を再試行", + "sources_row_retry_failed_tooltip": "取り込みに失敗しました。もう一度お試しください。", + "sources_row_select_aria": "{title}を選択", + "sources_unexpected_error": "予期しないエラーが発生しました", + "sources_upload_added_toast": "{count, plural, other {#件のソースを追加しました}}", + "sources_upload_duplicates_body": "追加済み: {files}", + "sources_upload_failed_toast": "{count, plural, other {ソースを追加できませんでした}}", + "sources_upload_ingesting_body": "取り込みはバックグラウンドで実行中です。", + "sources_upload_none_added_toast": "新しいソースは追加されませんでした", + "sources_upload_rejected_body": "拒否: {files}", + "sources_upload_rejected_file_label": "{filename}({reason})", + "sources_upload_unsupported_body": "非対応: {files}", + "sources_upload_unsupported_error": "非対応のファイル形式: {files}", + "studio_artifact_failed_toast": "{name}の生成に失敗しました", + "studio_artifact_failed_toast_body": "この生成物を生成できませんでした。生成物タブから再試行してください。", + "studio_artifact_list_clear_filter_button": "フィルターを解除", + "studio_artifact_list_empty": "生成された生成物はまだありません", + "studio_artifact_list_empty_body": "Studioで生成した生成物はここに表示されます。", + "studio_artifact_list_filtered_empty": "このフィルターに一致する生成物はありません", + "studio_artifact_list_title": "生成物", + "studio_artifact_panel_aria": "生成物", + "studio_artifact_panel_close_aria": "生成物を閉じる", + "studio_artifact_panel_download_aria": "ダウンロード", + "studio_artifact_panel_download_preview_aria": "プレビューをダウンロード", + "studio_artifact_panel_load_error": "生成物を読み込めませんでした", + "studio_artifact_panel_loading_status": "読み込み中…", + "studio_artifact_panel_title": "生成物", + "studio_artifact_ready_toast": "{name}の準備ができました", + "studio_artifact_row_actions_aria": "{name}の操作", + "studio_artifact_row_cancel_label": "キャンセル", + "studio_artifact_row_cancelled_tooltip": "キャンセルされました", + "studio_artifact_row_delete_label": "削除", + "studio_artifact_row_failed_tooltip": "生成に失敗しました", + "studio_artifact_row_open_label": "開く", + "studio_artifact_row_processing_aria": "{name}を処理中", + "studio_artifact_row_regenerate_label": "再生成", + "studio_artifact_row_retry_cancelled_aria": "キャンセルされました。{name}を再試行", + "studio_artifact_row_retry_cancelled_tooltip": "キャンセルされました。もう一度お試しください。", + "studio_artifact_row_retry_failed_aria": "生成に失敗しました。{name}を再試行", + "studio_artifact_row_retry_failed_tooltip": "生成に失敗しました。もう一度お試しください。", + "studio_artifact_row_retry_label": "再試行", + "studio_composer_brief_status": "概要を準備しています…", + "studio_composer_generate_button": "生成", + "studio_composer_prompt_label": "プロンプト(任意)", + "studio_composer_prompt_placeholder": "重点を指定できます(例:リスクを強調する)", + "studio_composer_sources_button": "{count, plural, other {#件のソース}}", + "studio_composer_sources_empty": "先にソースを追加してインデックスを作成してください。使用できるのは準備が完了したドキュメントのみです。", + "studio_composer_sources_label": "ソース", + "studio_delete_dialog_body": "{name}と生成されたファイルを完全に削除します。", + "studio_delete_dialog_cancel_button": "キャンセル", + "studio_delete_dialog_confirm_button": "生成物を削除", + "studio_delete_dialog_title": "{name}を削除しますか?", + "studio_delete_dialog_unnamed_body": "この生成物と生成されたファイルを完全に削除します。", + "studio_delete_dialog_unnamed_title": "この生成物を削除しますか?", + "studio_document_viewer_empty": "この生成物には本文がありません。", + "studio_docx_viewer_error_body": "このドキュメントはここではプレビューできません。ダウンロードして開いてください。", + "studio_docx_viewer_error_title": "このドキュメントを開けませんでした", + "studio_docx_viewer_load_error": "ドキュメントを読み込めませんでした({status})", + "studio_docx_viewer_oversize_error": "ドキュメントが大きすぎてプレビューできません({size}バイト)", + "studio_docx_viewer_retry_button": "再試行", + "studio_docx_viewer_zoom_in_aria": "拡大", + "studio_docx_viewer_zoom_out_aria": "縮小", + "studio_flashcard_surface_reveal_answer_aria": "答えを表示", + "studio_flashcard_surface_show_question_aria": "問題を表示", + "studio_flashcards_card_answer_label": "答え", + "studio_flashcards_card_mark_again_body": "現在の評価:要復習", + "studio_flashcards_card_mark_good_body": "現在の評価:覚えた", + "studio_flashcards_card_question_label": "問題", + "studio_flashcards_card_see_answer_body": "クリックして答えを表示", + "studio_flashcards_reset_dialog_body": "このデッキの「要復習」と「覚えた」の評価をすべて消去します。", + "studio_flashcards_reset_dialog_cancel_button": "キャンセル", + "studio_flashcards_reset_dialog_confirm_button": "進捗をリセット", + "studio_flashcards_reset_dialog_title": "フラッシュカードの進捗をリセットしますか?", + "studio_flashcards_viewer_again_aria": "要復習、{count, plural, other {#枚}}", + "studio_flashcards_viewer_again_button": "要復習", + "studio_flashcards_viewer_good_aria": "覚えた、{count, plural, other {#枚}}", + "studio_flashcards_viewer_good_button": "覚えた", + "studio_flashcards_viewer_load_error": "このフラッシュカードのデッキを読み込めませんでした", + "studio_flashcards_viewer_mark_error": "進捗を保存できませんでした", + "studio_flashcards_viewer_next_aria": "次のカード", + "studio_flashcards_viewer_previous_aria": "前のカード", + "studio_flashcards_viewer_progress_aria": "デッキの進捗", + "studio_flashcards_viewer_remaining_status": "{count, plural, other {残り#枚}}", + "studio_flashcards_viewer_reset_aria": "フラッシュカードの進捗をリセット", + "studio_flashcards_viewer_reset_error": "進捗をリセットできませんでした", + "studio_flashcards_viewer_shuffle_aria": "カードをシャッフル", + "studio_flashcards_viewer_shuffle_button": "シャッフル", + "studio_flashcards_viewer_shuffle_error": "シャッフルを保存できませんでした", + "studio_format_docx_label": "Word", + "studio_format_docx_tooltip": "ソースをもとにAIでWord文書を生成", + "studio_format_flashcards_label": "フラッシュカード", + "studio_format_flashcards_tooltip": "ソースをもとにAIでフラッシュカードを生成", + "studio_format_generic_tooltip": "ソースをもとに{format}を生成", + "studio_format_html_label": "Webページ", + "studio_format_html_tooltip": "ソースをもとにAIでインタラクティブなWebページを生成", + "studio_format_image_label": "画像", + "studio_format_image_tooltip": "ソースをもとにAIで画像を生成", + "studio_format_infographic_label": "インフォグラフィック", + "studio_format_infographic_tooltip": "ソースをもとにAIでインフォグラフィックを生成", + "studio_format_mindmap_label": "マインドマップ", + "studio_format_mindmap_tooltip": "ソースをもとにAIでマインドマップを生成", + "studio_format_needs_audio_model_label": "音声モデル", + "studio_format_needs_chat_model_label": "チャットモデル", + "studio_format_needs_image_edit_model_label": "画像編集モデル", + "studio_format_needs_image_model_label": "画像モデル", + "studio_format_needs_video_model_label": "動画モデル", + "studio_format_pdf_label": "PDF", + "studio_format_pdf_tooltip": "ソースをもとにAIでPDFを生成", + "studio_format_podcast_label": "ポッドキャスト", + "studio_format_podcast_tooltip": "ソースをもとにAIでポッドキャストを生成", + "studio_format_pptx_label": "スライド", + "studio_format_pptx_tooltip": "ソースをもとにAIでスライドを生成", + "studio_format_quiz_label": "クイズ", + "studio_format_quiz_tooltip": "ソースをもとにAIでインタラクティブなクイズを生成", + "studio_format_summary_label": "要約", + "studio_format_summary_tooltip": "ソースをもとにAIで要約を生成", + "studio_format_unavailable_tooltip": "{models}が必要です", + "studio_format_xlsx_label": "スプレッドシート", + "studio_format_xlsx_tooltip": "ソースをもとにAIでスプレッドシートを生成", + "studio_html_viewer_error_body": "このページはここではプレビューできません。ダウンロードして開いてください。", + "studio_html_viewer_error_title": "このページを開けませんでした", + "studio_html_viewer_retry_button": "再試行", + "studio_mindmap_viewer_fit_aria": "マインドマップ全体を表示", + "studio_mindmap_viewer_tree_aria": "マインドマップ", + "studio_panel_error_title": "Studioの操作に失敗しました", + "studio_panel_formats_aria": "Studioの形式", + "studio_pdf_viewer_error_title": "PDFを読み込めませんでした", + "studio_pdf_viewer_load_error": "PDFの取得中にサーバーが{status}を返しました", + "studio_pdf_viewer_retry_button": "再試行", + "studio_pdf_viewer_unknown_error": "PDFを読み込めませんでした", + "studio_pdf_viewer_zoom_in_aria": "拡大", + "studio_pdf_viewer_zoom_out_aria": "縮小", + "studio_podcast_brief_add_speaker_button": "話者を追加", + "studio_podcast_brief_language_label": "言語", + "studio_podcast_brief_length_label": "長さ", + "studio_podcast_brief_length_long_body": "約15分", + "studio_podcast_brief_length_long_label": "長め", + "studio_podcast_brief_length_short_body": "約3分", + "studio_podcast_brief_length_short_label": "短め", + "studio_podcast_brief_length_standard_body": "約8分", + "studio_podcast_brief_length_standard_label": "標準", + "studio_podcast_brief_remove_speaker_aria": "話者{number}を削除", + "studio_podcast_brief_role_cohost_label": "共同ホスト", + "studio_podcast_brief_role_expert_label": "専門家", + "studio_podcast_brief_role_guest_label": "ゲスト", + "studio_podcast_brief_role_host_label": "ホスト", + "studio_podcast_brief_role_narrator_label": "ナレーター", + "studio_podcast_brief_speaker_aria": "話者{number}", + "studio_podcast_brief_speaker_name_aria": "名前", + "studio_podcast_brief_speaker_role_aria": "役割", + "studio_podcast_brief_speaker_voice_aria": "声", + "studio_podcast_brief_speakers_label": "話者", + "studio_podcast_brief_style_conversational_label": "会話", + "studio_podcast_brief_style_debate_label": "ディベート", + "studio_podcast_brief_style_interview_label": "インタビュー", + "studio_podcast_brief_style_label": "スタイル", + "studio_podcast_brief_style_monologue_label": "モノローグ", + "studio_podcast_brief_style_narrative_label": "ナラティブ", + "studio_podcast_viewer_transcript_title": "文字起こし", + "studio_pptx_viewer_error_title": "プレゼンテーションを読み込めませんでした", + "studio_pptx_viewer_load_error": "プレゼンテーションの取得中にサーバーが{status}を返しました", + "studio_pptx_viewer_retry_button": "再試行", + "studio_pptx_viewer_unknown_error": "プレゼンテーションを読み込めませんでした", + "studio_quiz_review_correct_aria": "正解", + "studio_quiz_review_exit_button": "復習を終了", + "studio_quiz_review_explanation_title": "解説", + "studio_quiz_review_incorrect_aria": "不正解", + "studio_quiz_review_next_button": "次へ", + "studio_quiz_review_position_label": "問題{number}/{total}", + "studio_quiz_review_previous_button": "前へ", + "studio_quiz_review_title": "復習", + "studio_quiz_score_breakdown_aria": "スコアの内訳", + "studio_quiz_score_correct_section_title": "正解({count})", + "studio_quiz_score_correct_tab_label": "正解 {count}", + "studio_quiz_score_missed_section_title": "不正解({count})", + "studio_quiz_score_missed_tab_label": "不正解 {count}", + "studio_quiz_score_retake_all_label": "すべての問題に再挑戦", + "studio_quiz_score_retake_button": "クイズに再挑戦", + "studio_quiz_score_retake_missed_label": "間違えた問題に再挑戦", + "studio_quiz_score_review_button": "復習", + "studio_quiz_score_review_question_aria": "問題{number}を復習", + "studio_quiz_score_section_empty": "なし", + "studio_quiz_score_show_correct_aria": "{count, plural, other {正解した#問を表示}}", + "studio_quiz_score_show_missed_aria": "{count, plural, other {間違えた#問を表示}}", + "studio_quiz_score_show_skipped_aria": "{count, plural, other {スキップした#問を表示}}", + "studio_quiz_score_skipped_section_title": "スキップ({count})", + "studio_quiz_score_skipped_tab_label": "スキップ {count}", + "studio_quiz_score_title": "あなたのスコア", + "studio_quiz_viewer_answer_error": "回答を保存できませんでした", + "studio_quiz_viewer_correct_aria": "正解", + "studio_quiz_viewer_finish_button": "終了", + "studio_quiz_viewer_incorrect_aria": "不正解", + "studio_quiz_viewer_load_error": "このクイズを読み込めませんでした", + "studio_quiz_viewer_next_button": "次へ", + "studio_quiz_viewer_options_aria": "問題{number}の選択肢", + "studio_quiz_viewer_progress_aria": "クイズの進捗", + "studio_quiz_viewer_retake_error": "クイズをやり直せませんでした", + "studio_quiz_viewer_skip_button": "スキップ", + "studio_quiz_viewer_skip_error": "問題をスキップできませんでした", + "studio_quiz_viewer_title": "クイズに挑戦", + "studio_request_unexpected_error": "予期しないエラーが発生しました", + "studio_source_picker_back_button": "ソース({count}件選択中)", + "studio_source_picker_deselect_all_button": "すべて選択解除", + "studio_source_picker_select_all_button": "すべて選択", + "studio_summary_viewer_empty": "この生成物には本文がありません。", + "studio_type_filter_active_aria": "生成物を絞り込む({count}件適用中)", + "studio_type_filter_aria": "生成物を絞り込む", + "studio_type_filter_clear_label": "フィルターを解除", + "studio_type_filter_title": "種類で絞り込む", + "studio_xlsx_viewer_error_title": "このスプレッドシートを開けませんでした", + "studio_xlsx_viewer_load_error": "このスプレッドシートはここではプレビューできません。ダウンロードして開いてください。", + "studio_xlsx_viewer_oversize_error": "このブックは大きすぎるため、ここではプレビューできません。ダウンロードして開いてください。", + "studio_xlsx_viewer_retry_button": "再試行", + "studio_xlsx_viewer_sheet_aria": "ワークシート「{name}」", + "studio_xlsx_viewer_tabs_aria": "ワークシート", + "studio_xlsx_viewer_truncated_body": "{count, plural, other {最初の#行を表示しています。}}ブック全体を見るにはファイルをダウンロードしてください。", + "studio_xlsx_viewer_unreadable_error": "このブックを開けませんでした。ダウンロードして開いてください。", + "updates_settings_body": "GitHub Releasesから無料でアップデートできます。ネットワークでアプリのアップデートを許可するまで、SurfSenseは通信しません。許可すると起動時の確認も有効になります。", + "updates_settings_check_button": "今すぐ確認", + "updates_settings_check_error": "アップデートを確認できませんでした: {message}", + "updates_settings_checking_status": "確認中…", + "updates_settings_downloading_status": "{version}をダウンロード中…", + "updates_settings_ready_status": "SurfSense {version}のインストール準備ができました", + "updates_settings_restart_button": "再起動してアップデート", + "updates_settings_title": "アプリのアップデート", + "updates_settings_up_to_date_status": "SurfSenseは最新です", + "updates_title_bar_restart_tooltip": "再起動して{version}をインストール", + "workspaces_create_dialog_body": "ソースライブラリとチャットを別々に管理できます。", + "workspaces_create_dialog_submit_button": "作成", + "workspaces_create_dialog_title": "ワークスペースを作成", + "workspaces_delete_dialog_body": "チャット、ドキュメント、インデックス済みデータを完全に削除します。", + "workspaces_delete_dialog_cancel_button": "キャンセル", + "workspaces_delete_dialog_confirm_button": "ワークスペースを削除", + "workspaces_delete_dialog_title": "{name}を削除しますか?", + "workspaces_name_dialog_cancel_button": "キャンセル", + "workspaces_name_dialog_name_aria": "ワークスペース名", + "workspaces_name_dialog_saving_status": "保存中…", + "workspaces_rail_aria": "ワークスペース一覧", + "workspaces_rail_create_aria": "ワークスペースを作成", + "workspaces_rail_create_tooltip": "ワークスペースを作成", + "workspaces_rail_delete_label": "削除", + "workspaces_rail_rename_label": "名前を変更", + "workspaces_rail_settings_aria": "設定を開く", + "workspaces_rail_settings_tooltip": "設定", + "workspaces_rename_dialog_body": "この調査内容がわかる名前を付けてください。", + "workspaces_rename_dialog_submit_button": "名前を変更", + "workspaces_rename_dialog_title": "ワークスペースの名前を変更", + "workspaces_unexpected_error": "予期しないエラーが発生しました" +} From 6eaf0503aef5dcc154a6f986b941f873733c0871 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:08:55 +0530 Subject: [PATCH 06/38] feat(local): let main own the interface language and translate the View menu --- surfsense_local/electron/package.json | 1 + surfsense_local/electron/pnpm-lock.yaml | 37 ++++++++++++++++ .../electron/src/main/i18n/app-locale.ts | 37 ++++++++++++++++ .../electron/src/main/i18n/locale-ipc.ts | 32 ++++++++++++++ .../electron/src/main/i18n/locale-prefs.ts | 42 +++++++++++++++++++ .../electron/src/main/i18n/locales.ts | 9 ++++ .../electron/src/main/i18n/message-ids.d.ts | 10 +++++ .../src/main/i18n/resolve-locale.test.ts | 20 +++++++++ .../electron/src/main/i18n/resolve-locale.ts | 17 ++++++++ surfsense_local/electron/src/main/index.ts | 15 +++++-- surfsense_local/electron/src/preload/index.ts | 20 +++++++++ surfsense_local/electron/tsconfig.json | 3 +- 12 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 surfsense_local/electron/src/main/i18n/app-locale.ts create mode 100644 surfsense_local/electron/src/main/i18n/locale-ipc.ts create mode 100644 surfsense_local/electron/src/main/i18n/locale-prefs.ts create mode 100644 surfsense_local/electron/src/main/i18n/locales.ts create mode 100644 surfsense_local/electron/src/main/i18n/message-ids.d.ts create mode 100644 surfsense_local/electron/src/main/i18n/resolve-locale.test.ts create mode 100644 surfsense_local/electron/src/main/i18n/resolve-locale.ts diff --git a/surfsense_local/electron/package.json b/surfsense_local/electron/package.json index c8c5b1b50f..925889257f 100644 --- a/surfsense_local/electron/package.json +++ b/surfsense_local/electron/package.json @@ -37,6 +37,7 @@ "wait-on": "^9" }, "dependencies": { + "@formatjs/intl": "6.1.2", "electron-updater": "^6.8.9" } } diff --git a/surfsense_local/electron/pnpm-lock.yaml b/surfsense_local/electron/pnpm-lock.yaml index dac2ffede4..d4f89399e8 100644 --- a/surfsense_local/electron/pnpm-lock.yaml +++ b/surfsense_local/electron/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@formatjs/intl': + specifier: 6.1.2 + version: 6.1.2 electron-updater: specifier: ^6.8.9 version: 6.8.9 @@ -475,6 +478,18 @@ packages: cpu: [x64] os: [win32] + '@formatjs/fast-memoize@3.1.7': + resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} + + '@formatjs/icu-messageformat-parser@3.5.20': + resolution: {integrity: sha512-DUzCb2iskGDKvLU7P+nz/rjpmN59luoTRbWHFFNYNnZTxWCKvwPI94rNll99TGZaI6XoTbDF0QGegk21GFrQZQ==} + + '@formatjs/icu-skeleton-parser@2.1.12': + resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==} + + '@formatjs/intl@6.1.2': + resolution: {integrity: sha512-YH3Ldfa2vmavWgBlCyrT9WiobphTXlNHnHxhhB6nLSUOxmitJ6Bzqzg61uBcnc6yhKTU+YRo8hLhEatOZrVJUg==} + '@hapi/address@5.1.1': resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} engines: {node: '>=14.0.0'} @@ -1268,6 +1283,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + intl-messageformat@12.1.2: + resolution: {integrity: sha512-Y7ZnMtT/04nVJnjf1/x/AkfP5we/iN0AhckDdlFPtnetk6/VTuj2OPW5SE7steMd3UNaOtky4KJJY6jwyvWfJg==} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -2322,6 +2340,20 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@formatjs/fast-memoize@3.1.7': {} + + '@formatjs/icu-messageformat-parser@3.5.20': + dependencies: + '@formatjs/icu-skeleton-parser': 2.1.12 + + '@formatjs/icu-skeleton-parser@2.1.12': {} + + '@formatjs/intl@6.1.2': + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.20 + intl-messageformat: 12.1.2 + '@hapi/address@5.1.1': dependencies: '@hapi/hoek': 11.0.7 @@ -3226,6 +3258,11 @@ snapshots: inherits@2.0.4: {} + intl-messageformat@12.1.2: + dependencies: + '@formatjs/fast-memoize': 3.1.7 + '@formatjs/icu-messageformat-parser': 3.5.20 + is-fullwidth-code-point@3.0.0: {} isarray@1.0.0: {} diff --git a/surfsense_local/electron/src/main/i18n/app-locale.ts b/surfsense_local/electron/src/main/i18n/app-locale.ts new file mode 100644 index 0000000000..769bc65071 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/app-locale.ts @@ -0,0 +1,37 @@ +import { createIntl, createIntlCache, type IntlShape } from "@formatjs/intl" +import { app } from "electron" + +import de from "../../../../frontend/translations/de.json" +import en from "../../../../frontend/translations/en.json" +import ja from "../../../../frontend/translations/ja.json" +import type { Locale } from "./locales.ts" +import { resolveLocale, type LocalePreference } from "./resolve-locale.ts" + +// Main reads the same ICU catalogs as the renderer. It formats three menu +// labels, so it parses them at run time instead of precompiling. +const catalogs: Record> = { + en, + ja: { ...en, ...ja }, + de: { ...en, ...de }, +} +const cache = createIntlCache() + +let current: IntlShape = createIntl({ locale: "en", messages: en }, cache) + +// Main owns the interface language; windows are told through IPC. +export function applyLocalePreference(preference: LocalePreference): Locale { + const locale = resolveLocale(preference, app.getPreferredSystemLanguages()) + current = createIntl( + { locale, defaultLocale: "en", messages: catalogs[locale] }, + cache + ) + return locale +} + +export function currentLocale(): Locale { + return current.locale as Locale +} + +export function mainIntl(): IntlShape { + return current +} diff --git a/surfsense_local/electron/src/main/i18n/locale-ipc.ts b/surfsense_local/electron/src/main/i18n/locale-ipc.ts new file mode 100644 index 0000000000..cb31d9bf2c --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/locale-ipc.ts @@ -0,0 +1,32 @@ +import { BrowserWindow, ipcMain } from "electron" + +import { applyLocalePreference, currentLocale } from "./app-locale.ts" +import { + isLocalePreference, + loadLocalePreference, + saveLocalePreference, +} from "./locale-prefs.ts" + +// `locale:get` is sync so preload has the language before the first paint, +// the same way it reads the system theme. +export function registerLocaleHandlers(options: { + isTrusted: (sender: Electron.WebContents) => boolean + onChange: () => void +}): void { + ipcMain.on("locale:get", (event) => { + event.returnValue = currentLocale() + }) + + ipcMain.handle("locale:preference", () => loadLocalePreference()) + + ipcMain.handle("locale:set", (event, preference: unknown) => { + if (!options.isTrusted(event.sender)) return + if (!isLocalePreference(preference)) return + saveLocalePreference(preference) + const locale = applyLocalePreference(preference) + options.onChange() + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send("locale:changed", locale) + } + }) +} diff --git a/surfsense_local/electron/src/main/i18n/locale-prefs.ts b/surfsense_local/electron/src/main/i18n/locale-prefs.ts new file mode 100644 index 0000000000..61deeb0166 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/locale-prefs.ts @@ -0,0 +1,42 @@ +import { readFileSync, renameSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import { app } from "electron" + +import { isLocale } from "./locales.ts" +import type { LocalePreference } from "./resolve-locale.ts" + +function prefsPath(): string { + return join(app.getPath("userData"), "locale-prefs.json") +} + +export function isLocalePreference(value: unknown): value is LocalePreference { + return value === "system" || (typeof value === "string" && isLocale(value)) +} + +export function loadLocalePreference(): LocalePreference { + try { + const parsed: unknown = JSON.parse(readFileSync(prefsPath(), "utf8")) + const locale = + parsed && typeof parsed === "object" + ? (parsed as { locale?: unknown }).locale + : undefined + return isLocalePreference(locale) ? locale : "system" + } catch { + return "system" + } +} + +export function saveLocalePreference(locale: LocalePreference): void { + try { + const path = prefsPath() + const temporary = `${path}.tmp` + writeFileSync(temporary, JSON.stringify({ locale })) + renameSync(temporary, path) + } catch (error) { + // Best-effort, as theme-prefs.ts: a lost write falls back to "system". + process.stderr.write( + `[main] failed to save locale preference: ${String(error)}\n` + ) + } +} diff --git a/surfsense_local/electron/src/main/i18n/locales.ts b/surfsense_local/electron/src/main/i18n/locales.ts new file mode 100644 index 0000000000..672d4acbc5 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/locales.ts @@ -0,0 +1,9 @@ +// Mirrors frontend/src/i18n/locales.ts: the languages with a catalog in +// frontend/translations/. +export const LOCALES = ["en", "ja", "de"] as const +export type Locale = (typeof LOCALES)[number] +export const BASE_LOCALE: Locale = "en" + +export function isLocale(value: string): value is Locale { + return (LOCALES as readonly string[]).includes(value) +} diff --git a/surfsense_local/electron/src/main/i18n/message-ids.d.ts b/surfsense_local/electron/src/main/i18n/message-ids.d.ts new file mode 100644 index 0000000000..106ed2b5a1 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/message-ids.d.ts @@ -0,0 +1,10 @@ +import type en from "../../../../frontend/translations/en.json" + +// FormatJS's own typing hook: a message id outside en.json is a type error. +declare global { + namespace FormatjsIntl { + interface Message { + ids: keyof typeof en + } + } +} diff --git a/surfsense_local/electron/src/main/i18n/resolve-locale.test.ts b/surfsense_local/electron/src/main/i18n/resolve-locale.test.ts new file mode 100644 index 0000000000..40f51b562a --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/resolve-locale.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { resolveLocale } from "./resolve-locale.ts" + +test("an explicit choice wins over the system languages", () => { + assert.equal(resolveLocale("de", ["ja-JP", "en-US"]), "de") +}) + +test("system follows the first supported system language by its base", () => { + assert.equal(resolveLocale("system", ["fr-FR", "ja-JP", "de-DE"]), "ja") +}) + +test("system falls back to English when no system language is supported", () => { + assert.equal(resolveLocale("system", ["fr-FR", "zh-Hans-CN"]), "en") +}) + +test("an unknown stored choice is treated as system", () => { + assert.equal(resolveLocale("klingon", ["de-AT"]), "de") +}) diff --git a/surfsense_local/electron/src/main/i18n/resolve-locale.ts b/surfsense_local/electron/src/main/i18n/resolve-locale.ts new file mode 100644 index 0000000000..ac492c4cce --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/resolve-locale.ts @@ -0,0 +1,17 @@ +import { BASE_LOCALE, isLocale, type Locale } from "./locales.ts" + +export type LocalePreference = Locale | "system" + +// "system" takes the first OS language the app ships, matched on its base +// language, so ja-JP and de-AT resolve without listing every region. +export function resolveLocale( + preference: string, + systemLanguages: readonly string[] +): Locale { + if (preference !== "system" && isLocale(preference)) return preference + for (const tag of systemLanguages) { + const base = tag.split("-")[0]?.toLowerCase() + if (base && isLocale(base)) return base + } + return BASE_LOCALE +} diff --git a/surfsense_local/electron/src/main/index.ts b/surfsense_local/electron/src/main/index.ts index de37bb20ca..bc9747729c 100644 --- a/surfsense_local/electron/src/main/index.ts +++ b/surfsense_local/electron/src/main/index.ts @@ -56,6 +56,9 @@ import { type ThemePreference, } from "./theme-prefs.ts" import { loadWindowState, saveWindowState } from "./window-state.ts" +import { applyLocalePreference, mainIntl } from "./i18n/app-locale.ts" +import { loadLocalePreference } from "./i18n/locale-prefs.ts" +import { registerLocaleHandlers } from "./i18n/locale-ipc.ts" const DEV_RENDERER_URL = "http://localhost:5173" @@ -463,16 +466,16 @@ function installProductionMenu(): void { { role: "fileMenu" }, { role: "editMenu" }, { - label: "View", + label: mainIntl().formatMessage({ id: "menu_view_label" }), submenu: [ { - label: "Reload", + label: mainIntl().formatMessage({ id: "menu_view_reload_label" }), click: (_item, win) => { if (win instanceof BrowserWindow) win.reload() }, }, { - label: "Force Reload", + label: mainIntl().formatMessage({ id: "menu_view_force_reload_label" }), click: (_item, win) => { if (win instanceof BrowserWindow) { win.webContents.reloadIgnoringCache() @@ -576,8 +579,14 @@ function main(): void { app .whenReady() .then(async () => { + applyLocalePreference(loadLocalePreference()) const boot = await bootSidecars() registerDocumentHandlers(boot.dataDir) + registerLocaleHandlers({ + isTrusted: (sender) => + mainWindow !== null && sender === mainWindow.webContents, + onChange: installProductionMenu, + }) installProductionMenu() createWindow(boot.apiUrl) await registerUpdateHandlers() diff --git a/surfsense_local/electron/src/preload/index.ts b/surfsense_local/electron/src/preload/index.ts index 1b162cfeb8..a994459dff 100644 --- a/surfsense_local/electron/src/preload/index.ts +++ b/surfsense_local/electron/src/preload/index.ts @@ -12,6 +12,13 @@ ipcRenderer.on("theme:system-changed", (_event, theme: unknown) => { if (theme === "dark" || theme === "light") systemTheme = theme }) +// main owns the interface language; the sync read gives the page its language +// before first paint, and "locale:changed" keeps it live. +let locale = ipcRenderer.sendSync("locale:get") as string +ipcRenderer.on("locale:changed", (_event, next: unknown) => { + if (typeof next === "string") locale = next +}) + // the renderer talks HTTP to this base and never sees Node or the sidecars contextBridge.exposeInMainWorld("surfsense", { apiUrl: arg ? arg.slice(FLAG.length) : "http://127.0.0.1:8000", @@ -39,6 +46,19 @@ contextBridge.exposeInMainWorld("surfsense", { }): Promise => ipcRenderer.invoke("shell:titlebar-overlay", overlay), openExternal: (url: string): Promise => ipcRenderer.invoke("shell:open-external", url), + locale: { + get: (): string => locale, + preference: (): Promise => ipcRenderer.invoke("locale:preference"), + set: (preference: string): Promise => + ipcRenderer.invoke("locale:set", preference), + onChange: (listener: (locale: string) => void): (() => void) => { + const wrapped = (_event: unknown, next: unknown) => { + if (typeof next === "string") listener(next) + } + ipcRenderer.on("locale:changed", wrapped) + return () => ipcRenderer.removeListener("locale:changed", wrapped) + }, + }, theme: { set: (theme: "dark" | "light" | "system"): Promise => ipcRenderer.invoke("theme:set", theme), diff --git a/surfsense_local/electron/tsconfig.json b/surfsense_local/electron/tsconfig.json index 80d14712ad..3ee702dcd6 100644 --- a/surfsense_local/electron/tsconfig.json +++ b/surfsense_local/electron/tsconfig.json @@ -10,7 +10,8 @@ "noEmit": true, "esModuleInterop": true, "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "resolveJsonModule": true }, "include": ["src", "electron.vite.config.ts"] } From e7220acc5b7a292d6e8092827d2b341aed292116 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:08:55 +0530 Subject: [PATCH 07/38] feat(settings-ui): add a Language setting and translate Settings --- .../features/settings/appearance-toggle.tsx | 23 +++- .../settings/language-select.test.tsx | 49 +++++++ .../src/features/settings/language-select.tsx | 49 +++++++ .../settings/models/audio-models-settings.tsx | 5 +- .../settings/models/chat-models-settings.tsx | 5 +- .../settings/models/image-models-settings.tsx | 5 +- .../settings/models/model-slot-settings.tsx | 28 +++- .../src/features/settings/settings-dialog.tsx | 123 ++++++++++-------- 8 files changed, 213 insertions(+), 74 deletions(-) create mode 100644 surfsense_local/frontend/src/features/settings/language-select.test.tsx create mode 100644 surfsense_local/frontend/src/features/settings/language-select.tsx diff --git a/surfsense_local/frontend/src/features/settings/appearance-toggle.tsx b/surfsense_local/frontend/src/features/settings/appearance-toggle.tsx index d7166af4af..95e042b57e 100644 --- a/surfsense_local/frontend/src/features/settings/appearance-toggle.tsx +++ b/surfsense_local/frontend/src/features/settings/appearance-toggle.tsx @@ -1,13 +1,26 @@ import { useTheme } from "@/components/theme-provider" import { ComputerIcon, MoonIcon, SunIcon } from "@/components/ui/icons" import { SegmentedControl } from "@/components/ui/segmented-control" +import { intl } from "@/i18n/intl" const THEME_OPTIONS = [ - { icon: ComputerIcon, value: "system", label: "system" }, - { icon: SunIcon, value: "light", label: "light" }, - { icon: MoonIcon, value: "dark", label: "dark" }, + { icon: ComputerIcon, value: "system" }, + { icon: SunIcon, value: "light" }, + { icon: MoonIcon, value: "dark" }, ] as const +const SWITCH_TO_THEME_ARIA: Record< + (typeof THEME_OPTIONS)[number]["value"], + () => string +> = { + system: () => + intl.formatMessage({ id: "settings_appearance_switch_to_system_aria" }), + light: () => + intl.formatMessage({ id: "settings_appearance_switch_to_light_aria" }), + dark: () => + intl.formatMessage({ id: "settings_appearance_switch_to_dark_aria" }), +} + export function AppearanceToggle() { const { theme, setTheme } = useTheme() const selectedIndex = THEME_OPTIONS.findIndex( @@ -19,7 +32,7 @@ export function AppearanceToggle() { count={THEME_OPTIONS.length} selectedIndex={selectedIndex} role="radiogroup" - aria-label="Appearance" + aria-label={intl.formatMessage({ id: "settings_appearance_toggle_aria" })} > {THEME_OPTIONS.map((option) => { const Icon = option.icon @@ -33,7 +46,7 @@ export function AppearanceToggle() { value={option.value} checked={selected} className="peer sr-only" - aria-label={`Switch to ${option.label} theme`} + aria-label={SWITCH_TO_THEME_ARIA[option.value]()} onChange={() => setTheme(option.value)} /> diff --git a/surfsense_local/frontend/src/features/settings/language-select.test.tsx b/surfsense_local/frontend/src/features/settings/language-select.test.tsx new file mode 100644 index 0000000000..c92ceb269f --- /dev/null +++ b/surfsense_local/frontend/src/features/settings/language-select.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { LanguageSelect } from "./language-select" + +function fakeBridge(preference: string) { + const set = vi.fn(async () => {}) + window.surfsense = { + locale: { + get: () => "en", + preference: async () => preference, + set, + onChange: () => () => {}, + }, + } as unknown as Window["surfsense"] + return { set } +} + +describe("LanguageSelect", () => { + afterEach(() => { + cleanup() + window.surfsense = undefined + }) + + it("lists each language in its own name, after the system option", () => { + fakeBridge("system") + render() + const options = screen + .getAllByRole("option") + .map((option) => option.textContent) + expect(options).toEqual(["Match system", "English", "日本語", "Deutsch"]) + }) + + it("shows the saved preference", async () => { + fakeBridge("de") + render() + await waitFor(() => + expect(screen.getByRole("combobox").value).toBe("de") + ) + }) + + it("hands the choice to main", async () => { + const { set } = fakeBridge("system") + render() + await userEvent.selectOptions(screen.getByRole("combobox"), "ja") + expect(set).toHaveBeenCalledWith("ja") + }) +}) diff --git a/surfsense_local/frontend/src/features/settings/language-select.tsx b/surfsense_local/frontend/src/features/settings/language-select.tsx new file mode 100644 index 0000000000..187de56a9b --- /dev/null +++ b/surfsense_local/frontend/src/features/settings/language-select.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState, type ComponentProps } from "react" + +import { Select } from "@/components/ui/select" +import { LOCALES, type LocalePreference } from "@/i18n/locales" +import { intl } from "@/i18n/intl" + +// Each language in its own name, so a user in the wrong one can still find theirs. +function nativeName(locale: string): string { + return ( + new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale + ) +} + +export function LanguageSelect( + props: Omit, "value" | "onChange"> +) { + const [preference, setPreference] = useState("system") + + useEffect(() => { + let active = true + void window.surfsense?.locale?.preference().then((saved) => { + if (active) setPreference(saved as LocalePreference) + }) + return () => { + active = false + } + }, []) + + return ( + + ) +} diff --git a/surfsense_local/frontend/src/features/settings/models/audio-models-settings.tsx b/surfsense_local/frontend/src/features/settings/models/audio-models-settings.tsx index f7241b458a..6afad0472b 100644 --- a/surfsense_local/frontend/src/features/settings/models/audio-models-settings.tsx +++ b/surfsense_local/frontend/src/features/settings/models/audio-models-settings.tsx @@ -4,6 +4,7 @@ import { useAudioInstall } from "@/features/models/local/audio/use-audio-install import { useDeleteLocalAudioModel } from "@/features/models/local/audio/use-delete-local-audio-model" import { useSelect } from "@/features/models/selection/use-selection" import { useAudioModels } from "@/features/models/your-models/use-audio-models" +import { intl } from "@/i18n/intl" import { ModelSlotSettings } from "./model-slot-settings" @@ -20,8 +21,8 @@ export function AudioModelsSettings({ return ( @@ -91,7 +97,7 @@ export function ModelSlotSettings({ models.inUse === null const add = ( ) @@ -100,17 +106,25 @@ export function ModelSlotSettings({ {models.error ? ( - Could not load model settings + + {intl.formatMessage({ id: "settings_models_load_error" })} + {models.error.message} ) : models.isPending ? null : empty ? ( - No {slot} model yet + + {intl.formatMessage({ id: "settings_models_empty" }, { slot })} + {models.canDownload - ? "Download one to run on this computer, or use one from a server you already run." - : "Use one from a server you already run."} + ? intl.formatMessage({ + id: "settings_models_empty_download_body", + }) + : intl.formatMessage({ + id: "settings_models_empty_server_body", + })} {add} diff --git a/surfsense_local/frontend/src/features/settings/settings-dialog.tsx b/surfsense_local/frontend/src/features/settings/settings-dialog.tsx index 622885a2a0..da6567aad7 100644 --- a/surfsense_local/frontend/src/features/settings/settings-dialog.tsx +++ b/surfsense_local/frontend/src/features/settings/settings-dialog.tsx @@ -28,9 +28,11 @@ import type { ImportAccepted } from "@/features/migration/api" import { ImportBundleButton } from "@/features/migration/import-bundle" import type { ModelSelection } from "@/features/models/selection/api" import { UpdateSettings } from "@/features/updates/update-settings" +import { intl } from "@/i18n/intl" import { cn } from "@/lib/utils" import { AppearanceToggle } from "./appearance-toggle" +import { LanguageSelect } from "./language-select" import { AudioModelsSettings } from "./models/audio-models-settings" import { ChatModelsSettings } from "./models/chat-models-settings" import { ImageModelsSettings } from "./models/image-models-settings" @@ -38,7 +40,6 @@ import { SettingsSection } from "./settings-section" type SettingsNavItem = { id: SettingsSectionId - label: string icon: ComponentType<{ className?: string; strokeWidth?: number }> } @@ -65,30 +66,50 @@ function GeneralSettings({ }) { return (
-

Appearance

+

+ {intl.formatMessage({ id: "settings_general_appearance_title" })} +

- Choose how SurfSense looks on this device. + {intl.formatMessage({ id: "settings_general_appearance_body" })}

+
+
+

+ {intl.formatMessage({ id: "settings_general_language_title" })} +

+

+ {intl.formatMessage({ id: "settings_general_language_body" })} +

+
+ +

- Import from SurfSense cloud + {intl.formatMessage({ + id: "settings_general_cloud_import_title", + })}

-

- Upload the ZIP you exported from{" "} - - SurfSense cloud - - . +

+ {intl.formatMessage({ id: "settings_general_cloud_import_body" })}

+ + {intl.formatMessage({ id: "settings_general_cloud_import_link" })} +
@@ -129,38 +150,23 @@ function GeneralSettings({ // Add future settings pages here; the dialog navigation is generated from this list. const SETTINGS_SECTIONS = [ - { - id: "general", - label: "General", - icon: Settings2Icon, - }, - { - id: "chat-models", - label: "Chat", - icon: Chat01Icon, - }, - { - id: "image-models", - label: "Image", - icon: Image01Icon, - }, - { - id: "audio-models", - label: "Audio", - icon: AudioWaveformIcon, - }, - { - id: "network", - label: "Network", - icon: ComputerEthernetIcon, - }, - { - id: "license", - label: "License", - icon: LicenseIcon, - }, + { id: "general", icon: Settings2Icon }, + { id: "chat-models", icon: Chat01Icon }, + { id: "image-models", icon: Image01Icon }, + { id: "audio-models", icon: AudioWaveformIcon }, + { id: "network", icon: ComputerEthernetIcon }, + { id: "license", icon: LicenseIcon }, ] satisfies SettingsNavItem[] +const SECTION_LABELS: Record string> = { + general: () => intl.formatMessage({ id: "settings_nav_general_label" }), + "chat-models": () => intl.formatMessage({ id: "settings_nav_chat_label" }), + "image-models": () => intl.formatMessage({ id: "settings_nav_image_label" }), + "audio-models": () => intl.formatMessage({ id: "settings_nav_audio_label" }), + network: () => intl.formatMessage({ id: "settings_nav_network_label" }), + license: () => intl.formatMessage({ id: "settings_nav_license_label" }), +} + export function SettingsDialog({ open, section, @@ -186,18 +192,23 @@ export function SettingsDialog({ - Settings + + {intl.formatMessage({ id: "settings_dialog_title" })} + - Manage your SurfSense preferences. + {intl.formatMessage({ id: "settings_dialog_body" })}