diff --git a/.agents/skills/translate/SKILL.md b/.agents/skills/translate/SKILL.md new file mode 100644 index 0000000000..66b8212e4c --- /dev/null +++ b/.agents/skills/translate/SKILL.md @@ -0,0 +1,80 @@ +--- +name: translate +description: Adding, changing or translating interface strings in the SurfSense desktop app (surfsense_local). Use when adding a message to the code, moving hard-coded UI text into messages, translating into every language in LOCALES, or adding a language. 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; every other language in `LOCALES` is translated from it. How it works: [`docs/architecture/localization.md`](../../../docs/architecture/localization.md). + +| File | Holds | +|---|---| +| `translations/en.json` | generated by `formatjs extract` from every `defaultMessage` in the code; never edited by hand | +| `translations/.json` (`ja`, `de`, …) | `{"key": "ICU string"}`, flat, sorted, two-space indent, written with the steps below | +| [`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. `` is one of `_title`, `_body`, `_label`, `_placeholder`, `_button`, `_tooltip`, `_empty`, `_error`, `_toast`, `_aria`, `_link`, `_status`. +- 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. A link that is an action becomes its own key and its own element. +- Visible apostrophes are `’`, never `'`: ICU uses `'` as its escape character. +- Pass numbers raw, never preformatted text, and format them in the message with an ICU skeleton: `{size, number, ::unit/gigabyte .#}`, `{percent, number, ::percent}` (a fraction), `{downloads, number, ::compact-short}`. ICU cannot pick a unit, so megabytes and gigabytes are two messages. +- A count or position is `{count, number}`, never a plain `{count}`, which prints raw digits. Ids and HTTP status codes stay plain: they are not amounts. +- A date goes in as a `Date`, formatted in the message: `{date, date, ::yyyyMMMd}`. +- Named placeholders only: `{name}`, `{count}`. Plurals are ICU: `{count, plural, one {# document} other {# documents}}`; FormatJS prints `#` with the language's digit grouping. Never `(s)`. +- Write the English in the call, as FormatJS recommends: `intl.formatMessage({ id: "sources_list_empty", defaultMessage: "No sources yet" })`, or `intl.formatMessage({ id, defaultMessage }, { name })` with values. The id is literal and explicit. Import `intl` from `@/i18n/intl`. Then run `pnpm translations` in `surfsense_local/frontend` (or only `pnpm translations:extract`), which regenerates `en.json`; commit it with the code. +- A styled part of a sentence (a bold name, an accent) is an ICU tag, FormatJS's native rich text: `"Using {name} via {source}"`, rendered with `intl.formatMessage({ id, defaultMessage }, { b: (chunks) => {chunks} })`. An element can also be a value: `{ time: }`. +- Outside a message, numbers, dates, relative times, lists and language names go through `intl.formatNumber`, `formatDate`, `formatRelativeTime`, `formatList`, `formatDisplayName`, never a bare `Intl.*`, `toLocale*` or a raw `{count}` in JSX. + +New UI text, step by step: + +1. Write each piece of fixed text as `intl.formatMessage({ id, defaultMessage })`, following the rules above. +2. Run `pnpm translations` in `surfsense_local/frontend` to regenerate `en.json`. +3. Translate the new keys into every other language in `LOCALES` with the steps below. +4. Run the checks, then commit the code, `en.json` and every translated file together. + +## 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 for each language. Every added or changed line in that diff is a key to translate. +2. **Read the context.** The component that calls it (`grep -rn '""' 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, tag name and `select` branch exactly. Write the plural categories the language has, not the English ones: for example, Japanese only `other`, German `one` and `other`. +4. **Check.** From `surfsense_local/frontend`: `pnpm exec formatjs verify "translations/*.json" --source-locale en --missing-keys --extra-keys --structural-equality`, then `node scripts/check_translations.mjs` from the repo root. The `formatjs-verify` and `check-translations` pre-commit hooks run the same. Stop and fix on any failure; do not commit around it. + +## Adding a language + +1. Add the code to `LOCALES` in `frontend/src/i18n/locales.ts`, `electron/src/main/i18n/locales.ts` and `scripts/check_translations.mjs`. +2. Import its catalog into `catalogs` in `frontend/src/i18n/intl.ts` (from `./compiled/`), and add it to the cases in `plural-categories.test.ts`. +3. For a region code such as `pt-BR`, make `resolve-locale.ts` match the full tag; it matches the base language only. +4. Add a column to [`glossary.md`](glossary.md) and a line under **Tone**. +5. Create `translations/.json` by translating every key in `en.json` with the steps above. +6. Run the checks in **Translating** step 4, then `pnpm test`. +7. Open the app in that language and shorten any text that overflows. Never shrink the UI to fit. + +To check layout before any translation exists, pick **English (Pseudo-Accents)** (`en-XA`) under Settings › General in `pnpm dev`. It is longer, accented English; text that stays plain is not in a message. + +## 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, a placeholder name or a tag name. +- Never guess. When a key's meaning is unclear from 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..920f2be5c4 --- /dev/null +++ b/.agents/skills/translate/glossary.md @@ -0,0 +1,43 @@ +# 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, llama.cpp, GGUF, Hugging Face, API, MCP, and every model and provider name. German also keeps Workspace (`Mein Workspace`, `Workspace-Artefakte`); Japanese writes ワークスペース, since `マイWorkspace` reads as a mistake. + +## 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 | +| workspace | ワークスペース | Workspace | +| artifact (a Studio output) | 生成物 | Artefakt | +| chunk (a cited passage) | チャンク | Abschnitt | +| citation | 引用 | Quellenangabe | +| Model setup | モデル設定 | Modell-Einrichtung | +| vision (model badge) | 画像認識 | Vision | +| build (a quantized variant) | ビルド | Variante | +| in use | 使用中 | Aktiv | +| server (a model server) | サーバー | Server | +| provider | プロバイダー | Anbieter | +| this computer | このコンピューター | dieser Computer | +| retry | 再試行 | Erneut versuchen | +| View (menu) | 表示 | Darstellung | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d0f62f9ad5..fd0caeeb8d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,6 +86,36 @@ repos: always_run: true stages: [pre-commit] + # The desktop app's translation catalogs. formatjs-extract regenerates + # en.json from the code, so a stale en.json fails the commit as a modified + # file. formatjs-verify: every key translated, no extra keys, the same + # placeholders and tags. check-translations adds the SurfSense rules. + - repo: local + hooks: + - id: formatjs-extract + name: formatjs-extract + entry: formatjs extract "surfsense_local/frontend/src/**/*.{ts,tsx}" --ignore "**/*.test.*" --format simple --out-file surfsense_local/frontend/translations/en.json + language: node + additional_dependencies: ["@formatjs/cli@6.16.32"] + files: ^surfsense_local/frontend/(src/|translations/en\.json) + pass_filenames: false + stages: [pre-commit] + - id: formatjs-verify + name: formatjs-verify + entry: formatjs verify surfsense_local/frontend/translations/*.json --source-locale en --missing-keys --extra-keys --structural-equality + language: node + additional_dependencies: ["@formatjs/cli@6.16.32"] + files: ^surfsense_local/frontend/translations/ + pass_filenames: false + stages: [pre-commit] + - id: check-translations + name: check-translations + entry: node scripts/check_translations.mjs + language: system + files: ^surfsense_local/frontend/(translations/|src/features/) + pass_filenames: false + stages: [pre-commit] + # Commit message linting - repo: https://github.com/commitizen-tools/commitizen rev: v4.8.3 diff --git a/.vscode/settings.json b/.vscode/settings.json index 6c9c42dbe9..1a40d8e6c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "biome.configurationPath": "./surfsense_web/biome.json", + "biome.requireConfiguration": true, "deepscan.ignoreConfirmWarning": true, "python.defaultInterpreterPath": "${workspaceFolder}/surfsense_local/backend/.venv/bin/python", "basedpyright.analysis.extraPaths": [ diff --git a/AGENTS.md b/AGENTS.md index 1792bbb171..25e90c97e2 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 message in code (`en.json` is generated from it), 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/docs/adr/0029-icu-translation-catalogs.md b/docs/adr/0029-icu-translation-catalogs.md new file mode 100644 index 0000000000..e9e03ce9ed --- /dev/null +++ b/docs/adr/0029-icu-translation-catalogs.md @@ -0,0 +1,24 @@ +# ADR 0029: Interface text lives in ICU MessageFormat catalogs, one flat JSON file per language + +- **Status:** Accepted +- **Date:** 2026-09-24 +- **Source:** [localization proposal L49–116](https://github.com/MODSetter/SurfSense/blob/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884/docs/proposals/localization.md#L49-L116), [FormatJS ICU syntax](https://formatjs.github.io/docs/core-concepts/icu-syntax), commit [c3edca1b9](https://github.com/MODSetter/SurfSense/commit/c3edca1b9d44de178e7d8338d4a0b80144cd6fc7) + +## Context + +The desktop app's interface was English written into components. Japanese and German were next ([`03-international.md`](../../plans/community-local/seo/03-international.md)), and more languages would follow. The translations had to outlive whichever library renders them, be written by an agent and reviewed by anyone who reads the language, and survive plurals and word order that differ from English. Localization as built is in [localization](../architecture/localization.md). + +## Decision + +- One file per language in `surfsense_local/frontend/translations/`, a flat `{"id": "ICU string"}`. No nesting, no `common` block. `en.json` is generated from the code ([ADR 0030](0030-formatjs-renders-interface-text.md)); the others are written by translators. +- Messages are ICU MessageFormat 1: named placeholders, `plural` with each language's CLDR categories and `#`, `select`, and tags for styled parts of a sentence. +- Ids are `__`: the feature folder, then the component, then what the text does. Each place gets its own id, even when the English repeats. +- A value is a whole sentence. No joining two messages, no pieces around a link. +- Keys sorted with a two-space indent, the layout `formatjs extract` writes, the same order in every language. + +## Consequences + +- FormatJS, i18next with `i18next-icu`, Lingui and every major translation platform read the files, so the library can change without rewriting a translation. +- Word order stays with the translator: Japanese and German move placeholders and tags where their grammar needs them. +- Repetition in `en.json` is expected, and a feature's ids leave with the feature. +- The files hold no translator notes: the `simple` format has no field for them, and the key and the calling component, where the English is written, are the context. diff --git a/docs/adr/0030-formatjs-renders-interface-text.md b/docs/adr/0030-formatjs-renders-interface-text.md new file mode 100644 index 0000000000..5620e419b2 --- /dev/null +++ b/docs/adr/0030-formatjs-renders-interface-text.md @@ -0,0 +1,27 @@ +# ADR 0030: FormatJS renders interface text from precompiled, bundled catalogs, with no network at build or run time + +- **Status:** Accepted +- **Date:** 2026-09-25 +- **Source:** [localization proposal L35–47](https://github.com/MODSetter/SurfSense/blob/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884/docs/proposals/localization.md#L35-L47), [inlang: installing plugins](https://inlang.com/docs/install-plugin), [FormatJS performance guide](https://github.com/formatjs/formatjs/blob/main/docs/src/docs/guides/performance.mdx), commit [c03b68aaa](https://github.com/MODSetter/SurfSense/commit/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884) + +## Context + +The app is air-gapped, so no translation may be fetched at run time, and a release build should come only from the lockfile. The catalogs are ICU ([ADR 0029](0029-icu-translation-catalogs.md)). Paraglide JS was built first: it compiles messages to typed functions, but reads ICU only through a separate inlang plugin, first released in January 2026, that inlang recommends loading from a CDN; loading it from `node_modules` is supported but described as local-only. inlang also removed its lint rules in 2.0, and its native rich text needs inlang's own format. Localization as built is in [localization](../architecture/localization.md). + +## Decision + +- `react-intl` renders the frontend and `@formatjs/cli` compiles and checks the catalogs. Both come from npm, pinned by the lockfile. +- Electron main translates nothing. Its application menu is Electron roles only, labelled by Electron and the OS; translating the few labels main writes itself mixed two languages in one menu. +- `formatjs compile-folder --ast` precompiles the catalogs before every build, and Vite aliases the ICU parser to its no-parser build. +- All three languages are bundled. One `createIntl` instance serves the page, given to React through `RawIntlProvider`, and a language change reloads the window. +- Messages are declared inline with their usage, as the FormatJS docs recommend: `intl.formatMessage({ id, defaultMessage }, values)`, with an explicit id and no wrapper. `formatjs extract` generates `en.json` from them, and `@formatjs/unplugin` strips `defaultMessage` from the bundle. +- FormatJS's ESLint plugin checks every call: English present (`enforce-default-message`), every placeholder given (`enforce-placeholders`), the id shape (`enforce-id`). Ids are typed through `FormatjsIntl.Message`, keyed on `en.json`. + +## Consequences + +- The build needs no network, and text is on screen at the first render. Measured on the real catalogs: about 48 KB gzipped for all three languages, about 1 µs per message. +- Rich text, formatting, extraction, `verify` and the lint rules are FormatJS's own, so the project keeps only a plural-category test and a short check for its id prefix and file layout. +- `en.json` cannot hold a message the code does not use, or miss one it does. +- The English sits next to where it is shown, which makes calls longer. +- A language change drops in-memory state such as an open dialog. +- Replacing FormatJS means rewriting every call site; the catalogs stay. diff --git a/docs/adr/README.md b/docs/adr/README.md index 71d77711d5..4ae6431bfb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -63,3 +63,5 @@ The Date is the day the decision was first written down. Source links are permal | 0026 | [Curated models are ordered by their position in the manifest, with no score](0026-curated-order-is-list-position.md) | Accepted, in part revised by the model catalog proposal | | 0027 | [Egress consent is per host, so model search and downloads share one](0027-egress-consent-per-host.md) | Accepted | | 0028 | [Model-written code may run on the user's machine without a sandbox, and the agent asks before each shell command](0028-model-written-code-runs-with-approval.md) | Accepted | +| 0029 | [Interface text lives in ICU MessageFormat catalogs, one flat JSON file per language](0029-icu-translation-catalogs.md) | Accepted | +| 0030 | [FormatJS renders interface text from precompiled, bundled catalogs, with no network at build or run time](0030-formatjs-renders-interface-text.md) | Accepted | diff --git a/docs/architecture/chat.md b/docs/architecture/chat.md index 562e43152f..d5607f8cdf 100644 --- a/docs/architecture/chat.md +++ b/docs/architecture/chat.md @@ -76,7 +76,7 @@ The list handed to the generator is `[system, *history within budget, user]`. - Every frame is `data: {json}\n\n`, and the stream ends with `data: [DONE]\n\n` so a client can tell completion from a dropped connection. The response sends `Cache-Control: no-cache` and `X-Accel-Buffering: no` so nothing buffers it into one late blob. - A turn that fails before producing any text is deleted, both halves, and the stream goes from `error` straight to `[DONE]`. A turn that fails midway keeps its partial text. -- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `network`, `timeout` and `unknown`, each with a plain-language message. +- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `network`, `timeout` and `unknown`, each with a plain-language English message. The frontend shows its own translated text per kind ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)) and falls back to the backend's message for a kind it does not know. ## Citations @@ -124,7 +124,7 @@ The list handed to the generator is `[system, *history within budget, user]`. - A stream that ends without an error but yields no text still renames the thread and stores an empty assistant turn; the discard guard is `failed and not parts`. - `budget.py` prices the question at 1,024 tokens and says `MessageText` enforces that, but `MessageText` has no length limit, so a long question can push a turn past the model's window. -- The frontend's copy of the error kinds in `sse.ts` lacks `model_cannot_run` and `context_too_long`, so both offer Retry, which cannot fix either. +- `model_cannot_run` and `context_too_long` offer Retry in `chat-error-notice.tsx`, which cannot fix either. - No live region announces streamed text, and focus does not move to the conversation heading after a thread switch; the dashboard design asks for both. - No test covers a client disconnecting mid-reply. The assistant's text is written only when generation ends, inside the stream, so whether a disconnected reply is kept is unverified. - A thinking model spends the 1,024-token answer cap on its reasoning too: `max_tokens` counts what goes to `reasoning_content`, as the title measurement in [`local-models/runtime.md`](local-models/runtime.md#turning-thinking-off) shows, so on the local runtime a long think can cut the answer short or leave it empty. How often is unmeasured. diff --git a/docs/architecture/connections.md b/docs/architecture/connections.md index 282ef9c08e..50b73964a2 100644 --- a/docs/architecture/connections.md +++ b/docs/architecture/connections.md @@ -146,12 +146,16 @@ Loading a connection runs the egress check for its host. The bundled sd-server s Keys are protected by envelope encryption ([ADR 0018](../adr/0018-keychain-envelope-encryption.md)): -- Electron keeps one per-install secret: 32 random bytes, hex-encoded, encrypted with Electron's `safeStorage` (the OS keychain) into `secret.bin` in its userData folder, `/electron/`. If that file is missing or cannot be decrypted, after a reinstall or a keychain reset, Electron mints a new secret rather than refusing to start. +- Electron keeps one per-install secret: 32 random bytes, hex-encoded, encrypted with Electron's `safeStorage` (the OS keychain) into `secret.bin` in its userData folder, `/electron/`. If that file is missing or cannot be decrypted, after a reinstall or a keychain reset, Electron mints a new secret rather than refusing to start. `safeStorage` names its keychain item after the app, so development runs as "SurfSense Dev" and never reads or recreates the installed app's key. - On Linux without a keyring, `safeStorage` falls back to plain-text encryption so the app still boots. - Electron passes the secret as `SURFSENSE_LOCAL_SECRET` to the API and both workers, and to no other sidecar. The sidecars make the calls, so they need the keys, which is why the secret cannot stay inside Electron. - [`shared/secrets.py`](../../surfsense_local/backend/shared/secrets.py) derives a Fernet key from the SHA-256 of the secret and encrypts each connection's key into `provider_connections.api_key_ciphertext`. Revision `0007` dropped the plaintext column. `ProviderConnection.api_key` encrypts on write and decrypts on read. - A bare `uv run` with no secret in its environment creates `/secret`, 32 random bytes hex-encoded with mode `0600`, and logs a warning. The secret then sits next to the database it protects. - Ciphertext this install's secret cannot open, after a new secret was minted or a backup was restored on another machine, raises `UnreadableSecretError`. The API answers any route that hits it with `409` and code `unreadable_secret`: the request is valid, the server is healthy, and the stored key is what has to be entered again. +- Whether the saved chat model can be used has one answer, [`checkAvailability`](../../surfsense_local/frontend/src/features/models/selection/availability.ts), which startup and the dashboard both call: `available`, `gone` (no longer in its list), `needs-consent` (egress to its host is off) or `unusable` (an unreadable or rejected key, an unreachable endpoint, anything else). A failed check never fails startup, and never clears the saved choice: the model stays in the picker so it can be checked again once fixed. +- The dashboard checks again when the model changes, when Settings closes (a key entered again, egress switched, a connection edited) and after **Allow…**, so fixing the cause in Settings clears the notice without a reload. Startup's answer is its first status, so the same model is not checked twice. +- `unusable` holds the composer, and a notice attached above it names the reason from the error's code and opens Settings › Text gen ([`model-issue-notice.tsx`](../../surfsense_local/frontend/src/features/chat/model-issue-notice.tsx)). `needs-consent` holds it too, with a notice that names the host: **Allow…** opens the egress consent dialog and **Open settings** opens Settings › Network, so a send never raises the dialog. `gone` offers **Set up model** with no notice. +- `GET /llm/connections/{id}/models` answers an endpoint's failure with `502` and a code by exception type and status, never the message text: `provider_auth` (401, 403), `provider_rate_limited` (429), `provider_unreachable` (no connection or a timeout), otherwise `provider_error` ([`discovery_failure.py`](../../surfsense_local/backend/modules/llm/connections/discovery_failure.py)). - A key is never logged or returned. ## Frontend @@ -163,6 +167,5 @@ Keys are protected by envelope encryption ([ADR 0018](../adr/0018-keychain-envel ## Known gaps -- `ProviderConnection.api_key` still catches `InvalidToken` to treat a rotated secret as "no key", but `decrypt()` now raises `UnreadableSecretError`, so the catch never fires and `tests/unit/shared/test_secrets.py::test_rotated_secret_reads_as_no_key` fails. - An image returned as a URL is downloaded from whatever host the endpoint names, with no egress decision for that host; the endpoint's key is withheld from the download. - A model the remote catalog marks `unusable`, from an unreachable provider or served only on `/responses` or through another protocol, is still offered by `GET /llm/connections/{id}/models` and accepted by `choose_model()`, which ignore `call` and `connect.status`. diff --git a/docs/architecture/localization.md b/docs/architecture/localization.md new file mode 100644 index 0000000000..a9ba820215 --- /dev/null +++ b/docs/architecture/localization.md @@ -0,0 +1,92 @@ +# Localization + +The desktop app's interface is in English, Japanese and German, chosen from the OS languages or in Settings. Translations are ICU MessageFormat catalogs in the repo, precompiled and bundled into the app, so nothing is fetched at run time and the build needs no network. FormatJS renders them. + +**Code:** [`surfsense_local/frontend/translations/`](../../surfsense_local/frontend/translations/), [`surfsense_local/frontend/src/i18n/`](../../surfsense_local/frontend/src/i18n/), [`surfsense_local/electron/src/main/i18n/`](../../surfsense_local/electron/src/main/i18n/), [`scripts/check_translations.mjs`](../../scripts/check_translations.mjs), [`.agents/skills/translate/`](../../.agents/skills/translate/SKILL.md) +**Decisions:** [ADR 0029](../adr/0029-icu-translation-catalogs.md), [ADR 0030](../adr/0030-formatjs-renders-interface-text.md) + +## What is translated + +Only text written into the app's own code: labels, buttons, headings, in-app menus, tooltips, placeholders, empty states, toasts, dialogs, `aria-label`, `title` and `alt`, fixed sentences with a value inserted, and the frontend's text for a backend error code. + +Not translated: user content (workspace, document and thread names, file contents, chat messages), model output, backend and manifest data (model names and descriptions, provider names, file paths), the application menu, product and technical names used alone (SurfSense, Studio, llama.cpp, GGUF), and logs. The language a chat answer or a Studio output is written in is set elsewhere ([chat](chat.md), [Studio](studio.md)). + +English is the source. Japanese and German came first, in the order [`03-international.md`](../../plans/community-local/seo/03-international.md) set for the website. + +## Catalogs + +One flat JSON file per language, `{"id": "ICU string"}`, keys sorted with a two-space indent, the same order in every file ([ADR 0029](../adr/0029-icu-translation-catalogs.md)). `en.json` is generated: `formatjs extract` reads every `defaultMessage` in the code. `ja.json` and `de.json` are written by the `translate` skill and reviewers. + +```json +{ + "onboarding_model_ready_server_status": "Using {name} via {source}", + "sources_delete_dialog_title": "Delete {count, plural, one {# source} other {# sources}}?" +} +``` + +- An id is `__`. `` is a folder under `src/features/`, or `app` for the shell. `` is one of `title`, `body`, `label`, `placeholder`, `button`, `tooltip`, `empty`, `error`, `toast`, `aria`, `link`, `status`. A backend code is `_error_`. +- Each place has its own id, even where the English repeats. There is no `common` block. +- A value is a whole sentence with named placeholders. Plurals are ICU `plural` with the language's CLDR categories and `#`; Japanese writes only `other`. Styled parts of a sentence are tags. +- Visible apostrophes are `’`: ICU uses `'` as its escape character. +- Numbers go in raw and the message formats them with an ICU skeleton, as FormatJS's best practices ask: `{size, number, ::unit/gigabyte .#}`, `{percent, number, ::percent}`, `{downloads, number, ::compact-short}`. Each language then writes 1,2 GB, 40 % or 1.2万 its own way. +- A count or position is `{count, number}`, never a plain `{count}`, which prints raw digits in every language. Identifiers stay plain on purpose: a chunk id (`#{id}`) and an HTTP status (`{status}`) are not amounts. +- A date goes in as a `Date` and the message formats it with a date skeleton: `{date, date, ::yyyyMMMd}` reads Oct 18, 2026, 18. Okt. 2026 or 2026年10月18日. +- A number shown outside any message, such as a `3 / 10` counter or a score, goes through `intl.formatNumber`. + +## Rendering + +- [`intl.ts`](../../surfsense_local/frontend/src/i18n/intl.ts) builds one `createIntl` instance for the page, in the language preload reports, with all three precompiled catalogs bundled and each merged over English. [`main.tsx`](../../surfsense_local/frontend/src/main.tsx) gives it to React through `RawIntlProvider`. +- Every call declares its English inline, as the FormatJS docs recommend: `intl.formatMessage({ id: "sources_list_empty", defaultMessage: "No sources yet" })`. The id is literal and explicit. [`message-ids.d.ts`](../../surfsense_local/frontend/src/i18n/message-ids.d.ts) types the ids through `FormatjsIntl.Message`, so an id outside `en.json` is a type error. +- The build strips `defaultMessage` (`@formatjs/unplugin` with `removeDefaultMessage`), so each message ships once, in the catalogs. +- Rich text is FormatJS's: `{ b: (chunks) => {chunks} }` for a tag, and an element can be a value, such as `` in "Last call: {time}". +- Numbers, dates, relative times, lists and language names go through `intl.formatNumber`, `formatDate`, `formatRelativeTime`, `formatList` and `formatDisplayName`. +- ESLint forbids importing the catalogs or `compiled/` outside `src/i18n/`. + +## Locale + +- Main resolves it: a saved choice, else the first entry of `app.getPreferredSystemLanguages()` whose base language ships (`ja-JP` → `ja`), else English ([`resolve-locale.ts`](../../surfsense_local/electron/src/main/i18n/resolve-locale.ts)). +- Settings › General has a Language select beside Appearance: Match system, then each language in its own name. The choice is `locale-prefs.json` in `userData`. +- Preload reads the locale synchronously before first paint and exposes `locale.get()`, `preference()`, `set()` and `onChange()`. `index.html` sets `` in the first frame, and [`locale.ts`](../../surfsense_local/frontend/src/i18n/locale.ts) keeps it. +- A change saves the choice and reloads the window, which builds its `IntlShape` in the new language. In-memory state, such as an open dialog, resets. +- Development also lists FormatJS's pseudo-locale `en-XA`: accented English, about 40% longer and bracketed, so overflow, clipping and text outside a message show up without a translation. The renderer lists it only under Vite dev, and main accepts it only when the app is not packaged; a production bundle does not contain it. + +## Main process + +Main resolves the language ([`app-locale.ts`](../../surfsense_local/electron/src/main/i18n/app-locale.ts)) but translates nothing. The application menu is the same in dev and packaged builds, built only from Electron `role`s, with Developer Tools added while unpackaged. Its labels come from Electron and the OS, never from the in-app choice; on macOS a per-app language is set in System Settings › Language & Region. Translating the few labels main wrote itself left one menu in two languages. The OS localizes role labels on macOS only for languages whose `.lproj` ships, so `mac.electronLanguages` in [`electron-builder.yml`](../../surfsense_local/electron/electron-builder.yml) must keep `en`, `ja` and `de` if it is ever narrowed ([electron#26231](https://github.com/electron/electron/issues/26231)). + +## Backend text + +The backend stays English. Where it sends a code with its prose, the frontend shows its own text for the code and falls back to the backend's English for a code it does not know: the chat's error kinds ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)) and the license rejection reasons ([`license-error-text.ts`](../../surfsense_local/frontend/src/features/license/license-error-text.ts)). + +## Build + +`pnpm translations` runs before `dev`, `build`, `typecheck` and `test`: + +1. `formatjs extract` writes `translations/en.json` from every `defaultMessage` in `frontend/src`. +2. `formatjs compile-folder translations src/i18n/compiled --format simple --ast` precompiles the catalogs, failing on a malformed message. + +Each step is also its own script, `pnpm translations:extract` and `pnpm translations:compile`. `dev` alone also runs `pnpm translations:pseudo`, `formatjs compile translations/en.json --ast --pseudo-locale en-XA`, which writes the pseudo-locale's catalog; `intl.ts` reads it through `import.meta.glob`, so a build without it still compiles. + +Vite aliases `@formatjs/icu-messageformat-parser` to its no-parser build, since no message is parsed at run time. `compiled/` is gitignored. Everything comes from npm and the lockfile. + +## Checks + +- ESLint, with FormatJS's plugin: `enforce-default-message` (every call carries its English), `enforce-placeholders` (every placeholder gets a value), and `enforce-id` (ids match `__`). +- `formatjs-extract`, a pre-commit hook: re-runs extraction, so a commit whose `en.json` does not match the code fails as a modified file. +- `formatjs-verify`, a pre-commit hook: `formatjs verify --missing-keys --extra-keys --structural-equality` over the three catalogs. +- `check-translations`, a pre-commit hook: [`check_translations.mjs`](../../scripts/check_translations.mjs) for the rules FormatJS does not know: an id prefix that is not a feature folder or `app`, a leading or trailing space, a straight apostrophe, an unsorted file. +- [`plural-categories.test.ts`](../../surfsense_local/frontend/src/i18n/plural-categories.test.ts), in `pnpm test`: every plural writes exactly the categories `Intl.PluralRules` gives its language. + +[`code-quality.yml`](../../.github/workflows/code-quality.yml) runs the hooks on a non-draft pull request's changed files. + +## Translating + +Developers write English only, inline in the `formatMessage` call; `pnpm translations` or the pre-commit hook updates `en.json`. The [`translate`](../../.agents/skills/translate/SKILL.md) skill drafts Japanese and German for every id that is new or whose English changed, with its glossary and tone (です/ます; German *du*). Anyone who reads a language can correct it in a pull request; no release waits on review. No machine-translation service is used ([ADR 0017](../adr/0017-egress-off-by-default.md)). + +## Known gaps + +- `enforce-placeholders` checks values only when they are passed as an object literal; a call that passes a variable, as `model-ready.tsx` does, is not checked. +- `plural-categories.test.ts` runs only in `pnpm test`, and no workflow runs the desktop tests on pull requests. +- Backend prose without a code stays English in every language: model install messages, fit verdicts, `not_runnable_reason`, a Studio format's `unavailable_reason`, and the disk-space `detail` that `lib/api.ts` wraps in a translated sentence. +- Studio's fallback "Needs {models}" joins translated noun phrases with `formatList`, so German case agreement is not guaranteed. It shows only when a format lacks the backend's `unavailable_reason`. +- A chat failure caught before the stream starts shows the `unknown` kind's text, not the error's own detail. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index ffbd71a4b1..e27b3f5617 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -112,6 +112,7 @@ The rules that keep the processes out of each other's way: - [Studio](studio.md): artifact formats, jobs, viewers. - [Connections](connections.md): OpenAI-compatible endpoints and where keys live. - Local models: [runtime](local-models/runtime.md), [fit](local-models/fit.md), [catalog](local-models/catalog.md), [selection and onboarding](local-models/selection.md). +- [Localization](localization.md): the interface in English, Japanese and German. - [Egress](egress.md), [import](import.md), [license in the app](license/app.md), [license portal](license/portal.md), [updates](updates.md), [packaging](packaging.md), [sunset](sunset.md). - [Contracts](../contracts/README.md) between the trees. diff --git a/docs/architecture/packaging.md b/docs/architecture/packaging.md index 064edcf61d..b6ec4e24e2 100644 --- a/docs/architecture/packaging.md +++ b/docs/architecture/packaging.md @@ -20,6 +20,8 @@ The asar holds only the Electron main and preload bundles. Everything else rides | `sdcpp` | `electron/sdcpp` | `sd-server`, for local image generation | | `audiocpp` | `electron/audiocpp` | `audiocpp_server`, its libraries, the curated model specs and eSpeak-ng, for podcast voices | +The app icon lives in `electron/build/icons/`: `packaged/` holds the `.icns`, `.ico` and `.png` that `electron-builder.yml` names per OS, and `dev/` a variant with a "DEV" badge, which `electron/src/main/dev-app-identity.ts` sets on the Dock, taskbar, window and About panel only while unpackaged, alongside the name "SurfSense Dev", because development runs inside Electron's own bundle and would otherwise show Electron's icon. The macOS menu bar name and the About panel icon stay Electron’s in development; only packaging changes them. Artwork on Windows and Linux fills its canvas; on macOS it sits at 824 of 1024 px with a transparent margin, Apple's icon grid, so `icon.icns` and `dev/icon-macos.png` carry that margin and the `.ico` and `.png` files do not. + Packaged, Electron runs `resources/backend/api/api` and one `worker` process per queue, `ingest` and `studio`, and gives both `SURFSENSE_LOCAL_MODELS_DIR` pointing at `resources/models` and `HF_HUB_OFFLINE=1` (`electron/src/main/sidecars/python.ts`). In development the same sidecars run through `uv run`. ## Freezing the backend diff --git a/scripts/check_translations.mjs b/scripts/check_translations.mjs new file mode 100644 index 0000000000..1a40dbbdce --- /dev/null +++ b/scripts/check_translations.mjs @@ -0,0 +1,78 @@ +// Check the desktop app's translation catalogs for the SurfSense rules that +// FormatJS's own tools do not know. `formatjs extract` writes en.json from the +// code, `formatjs verify` owns missing and extra keys and placeholder +// structure, and eslint-plugin-formatjs owns the id shape and placeholder +// values. No dependencies, so the pre-commit job needs only the runner's Node. +// +// Run: node scripts/check_translations.mjs +// Exit code is 1 when anything is wrong. + +import { readdirSync, readFileSync, statSync } from "node:fs" +import { join, relative } from "node:path" +import { fileURLToPath } from "node:url" + +const ROOT = join(fileURLToPath(import.meta.url), "..", "..") +const FRONTEND = join(ROOT, "surfsense_local", "frontend") +const TRANSLATIONS = join(FRONTEND, "translations") +const FEATURES = join(FRONTEND, "src", "features") +const LOCALES = ["en", "ja", "de"] +// The one prefix that is not a feature folder: the app shell. +const EXTRA_PREFIXES = ["app"] + +const problems = [] +const fail = (where, message) => problems.push(`${where}: ${message}`) + +function readCatalog(locale) { + const path = join(TRANSLATIONS, `${locale}.json`) + const where = relative(ROOT, path) + let text + try { + text = readFileSync(path, "utf8") + } catch { + fail(where, "missing") + return {} + } + let data + try { + data = JSON.parse(text) + } catch (error) { + fail(where, `not JSON: ${error.message}`) + return {} + } + for (const [key, value] of Object.entries(data)) { + if (typeof value !== "string") fail(where, `${key} is not a string (no nesting)`) + else if (value !== value.trim()) fail(where, `${key} has a leading or trailing space`) + // ICU's escape character; FormatJS's syntax guide asks for ’ in visible text. + else if (value.includes("'")) fail(where, `${key} has a straight apostrophe; write ’`) + } + // The layout `formatjs extract` writes for en.json, kept by hand in the others. + const sorted = Object.fromEntries( + Object.entries(data).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + ) + if (text !== `${JSON.stringify(sorted, null, 2)}\n`) { + fail(where, "not sorted with two-space indent; rewrite it with keys in order") + } + return data +} + +const catalogs = Object.fromEntries(LOCALES.map((l) => [l, readCatalog(l)])) + +const features = new Set([ + ...readdirSync(FEATURES).filter((name) => + statSync(join(FEATURES, name)).isDirectory() + ), + ...EXTRA_PREFIXES, +]) + +// A deleted feature folder leaves its ids behind with a prefix nothing owns. +for (const key of Object.keys(catalogs.en)) { + if (!features.has(key.split("_")[0])) { + fail("translations/en.json", `${key} does not start with a feature folder or app`) + } +} + +for (const problem of problems) console.log(problem) +console.log( + `\nchecked ${Object.keys(catalogs.en).length} keys in ${LOCALES.length} languages, ${problems.length} problems` +) +process.exit(problems.length > 0 ? 1 : 0) diff --git a/surfsense_local/AGENTS.md b/surfsense_local/AGENTS.md index ac456a2944..3f6bd6ecf0 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/`: `translate` skill. The application menu is Electron roles only and is not translated. ## Do not diff --git a/surfsense_local/backend/modules/llm/connections/discovery_failure.py b/surfsense_local/backend/modules/llm/connections/discovery_failure.py new file mode 100644 index 0000000000..6028c31c4d --- /dev/null +++ b/surfsense_local/backend/modules/llm/connections/discovery_failure.py @@ -0,0 +1,28 @@ +import httpx +from fastapi import HTTPException, status + +_AUTH_STATUS_CODES = {401, 403} + + +def discovery_failure(error: httpx.HTTPError) -> HTTPException: + """A 502 whose code says what the endpoint did, so the UI can name the fix. + + By exception type and status only, never the message text, as chat's + classify_chat_error does. + """ + if isinstance(error, httpx.HTTPStatusError): + answered = error.response.status_code + if answered in _AUTH_STATUS_CODES: + code = "provider_auth" + elif answered == 429: + code = "provider_rate_limited" + else: + code = "provider_error" + elif isinstance(error, httpx.TransportError): + code = "provider_unreachable" + else: + code = "provider_error" + return HTTPException( + status.HTTP_502_BAD_GATEWAY, + {"code": code, "message": "connection model discovery failed"}, + ) diff --git a/surfsense_local/backend/modules/llm/connections/router.py b/surfsense_local/backend/modules/llm/connections/router.py index 06558df768..458bdaa0bb 100644 --- a/surfsense_local/backend/modules/llm/connections/router.py +++ b/surfsense_local/backend/modules/llm/connections/router.py @@ -10,6 +10,7 @@ from modules.egress import service as egress from modules.llm.catalog.remote.manifest.loader import remote_lookup from modules.llm.catalog.remote.rows import CUSTOM +from modules.llm.connections.discovery_failure import discovery_failure from modules.llm.connections.service import ( discover_models, normalize_base_url, @@ -205,9 +206,7 @@ async def list_connection_models( try: models = await discover_models(connection) except httpx.HTTPError as error: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, "connection model discovery failed" - ) from error + raise discovery_failure(error) from error except ValueError as error: raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(error)) from error return [ diff --git a/surfsense_local/backend/modules/llm/models.py b/surfsense_local/backend/modules/llm/models.py index 5b40131e11..19a2d55c0d 100644 --- a/surfsense_local/backend/modules/llm/models.py +++ b/surfsense_local/backend/modules/llm/models.py @@ -1,6 +1,5 @@ from datetime import datetime -from cryptography.fernet import InvalidToken from sqlalchemy import CheckConstraint, ForeignKey, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column @@ -101,14 +100,10 @@ class ProviderConnection(Base): def api_key(self) -> str | None: if self.api_key_ciphertext is None: return None - try: - return decrypt(self.api_key_ciphertext) - except InvalidToken: - # ponytail: SURFSENSE_LOCAL_SECRET rotated (secret.bin reminted). - # Drop the unreadable blob so the UI asks for a new key; a 500 - # here takes down model discovery for an otherwise healthy API. - self.api_key_ciphertext = None - return None + # A key this install's secret cannot open raises UnreadableSecretError, + # which the API answers as 409 `unreadable_secret`: kept, so the user is + # told to enter it again rather than finding it silently gone. + return decrypt(self.api_key_ciphertext) @api_key.setter def api_key(self, value: str | None) -> None: diff --git a/surfsense_local/backend/tests/integration/llm/conftest.py b/surfsense_local/backend/tests/integration/llm/conftest.py index 6675ceab4c..29a229d2d1 100644 --- a/surfsense_local/backend/tests/integration/llm/conftest.py +++ b/surfsense_local/backend/tests/integration/llm/conftest.py @@ -161,6 +161,9 @@ async def download(url, destination, *, sha256=None, transport=None): REASONING_DELTAS = ["Okay, "] * 200 REMOTE_THINKS = False REMOTE_REQUESTS: list[tuple[str, str]] = [] +# Set to an HTTP status to make /models refuse, as a provider rejecting the key +# or rate-limiting would; the fixture resets it. +MODELS_STATUS: int | None = None class StubOpenAICompatible(BaseHTTPRequestHandler): @@ -168,7 +171,9 @@ class StubOpenAICompatible(BaseHTTPRequestHandler): def do_GET(self) -> None: parsed = urlsplit(self.path) - if parsed.path == "/models": + if parsed.path == "/models" and MODELS_STATUS is not None: + self.send_error(MODELS_STATUS) + elif parsed.path == "/models": image_only = parse_qs(parsed.query).get("output_modalities") == ["image"] models = REMOTE_MODELS[1:] if image_only else REMOTE_MODELS[:1] self._json({"object": "list", "data": models}) @@ -213,7 +218,9 @@ def log_message(self, *args: object) -> None: @pytest.fixture def openai_server() -> Iterator[str]: """A real OpenAI-compatible endpoint on a real port.""" + global MODELS_STATUS REMOTE_REQUESTS.clear() + MODELS_STATUS = None server = ThreadingHTTPServer(("127.0.0.1", 0), StubOpenAICompatible) threading.Thread(target=server.serve_forever, daemon=True).start() url = f"http://127.0.0.1:{server.server_port}" diff --git a/surfsense_local/backend/tests/integration/llm/test_connections.py b/surfsense_local/backend/tests/integration/llm/test_connections.py index 869d7df623..ed2fad2cbd 100644 --- a/surfsense_local/backend/tests/integration/llm/test_connections.py +++ b/surfsense_local/backend/tests/integration/llm/test_connections.py @@ -553,3 +553,58 @@ async def test_a_key_the_app_can_no_longer_read_is_explained_not_a_crash( detail = listed.json()["detail"] assert detail["code"] == "unreadable_secret" assert "key" in detail["message"].casefold() + + +def _closed_port() -> int: + import socket + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +@pytest.mark.parametrize( + ("answered", "code"), + [ + (401, "provider_auth"), + (403, "provider_auth"), + (429, "provider_rate_limited"), + (503, "provider_error"), + ], +) +async def test_a_failed_model_listing_names_what_the_provider_answered( + client: AsyncClient, openai_server: str, answered: int, code: str +) -> None: + """Startup and Settings say why a model is unusable, not only that it is.""" + connection = await _connect(client, openai_server) + conftest.MODELS_STATUS = answered + + listed = await client.get(f"/llm/connections/{connection['id']}/models") + + assert listed.status_code == 502 + assert listed.json()["detail"]["code"] == code + + +async def test_an_unreachable_provider_is_named_as_unreachable( + client: AsyncClient, openai_server: str, engine +) -> None: + """Offline, or the endpoint gone, is told apart from a refused key.""" + connection = await _connect(client, openai_server) + # The endpoint goes away after it was saved, as an offline machine sees it. + from sqlalchemy import update + + from modules.llm.models import ProviderConnection + from shared.db import create_session_factory + + with create_session_factory(engine)() as session: + session.execute( + update(ProviderConnection) + .where(ProviderConnection.id == connection["id"]) + .values(base_url=f"http://127.0.0.1:{_closed_port()}") + ) + session.commit() + + listed = await client.get(f"/llm/connections/{connection['id']}/models") + + assert listed.status_code == 502 + assert listed.json()["detail"]["code"] == "provider_unreachable" diff --git a/surfsense_local/backend/tests/unit/artifacts/test_podcast_brief.py b/surfsense_local/backend/tests/unit/artifacts/test_podcast_brief.py index cbaa6d64af..2a51a9c65c 100644 --- a/surfsense_local/backend/tests/unit/artifacts/test_podcast_brief.py +++ b/surfsense_local/backend/tests/unit/artifacts/test_podcast_brief.py @@ -3,6 +3,8 @@ from modules.artifacts.podcast import brief from modules.llm.providers.protocols import Voice +pytestmark = pytest.mark.unit + VOICES = [ Voice("af_heart", "Heart", ("en-US",)), Voice("am_adam", "Adam", ("en-US",)), diff --git a/surfsense_local/backend/tests/unit/documents/test_upload_validation.py b/surfsense_local/backend/tests/unit/documents/test_upload_validation.py index dba7936b9f..74c29d69e2 100644 --- a/surfsense_local/backend/tests/unit/documents/test_upload_validation.py +++ b/surfsense_local/backend/tests/unit/documents/test_upload_validation.py @@ -11,6 +11,8 @@ validate_upload, ) +pytestmark = pytest.mark.unit + def test_the_public_allowlist_is_deliberately_small() -> None: """A dependency upgrade must not silently expose another Docling format.""" diff --git a/surfsense_local/backend/tests/unit/shared/test_secrets.py b/surfsense_local/backend/tests/unit/shared/test_secrets.py index 586ea05be1..d1678111ad 100644 --- a/surfsense_local/backend/tests/unit/shared/test_secrets.py +++ b/surfsense_local/backend/tests/unit/shared/test_secrets.py @@ -3,6 +3,8 @@ from modules.llm.models import ProviderConnection from shared.secrets import UnreadableSecretError, decrypt, encrypt +pytestmark = pytest.mark.unit + def test_api_key_round_trips_and_is_not_stored_in_clear() -> None: """The column holds ciphertext; the attribute still reads the key back.""" @@ -33,8 +35,10 @@ def test_unreadable_ciphertext_fails_closed_as_a_named_condition() -> None: decrypt(bytes(token)) -def test_rotated_secret_reads_as_no_key(monkeypatch: pytest.MonkeyPatch) -> None: - """secret.bin reminted: the old ciphertext is gone, not a 500.""" +def test_a_key_saved_under_a_lost_secret_is_named_not_dropped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """secret.bin reminted: the key says why it is unreadable and stays stored.""" connection = ProviderConnection( label="x", provider="openai_compatible", base_url="http://h", api_key="sk-1" ) @@ -47,5 +51,6 @@ def test_rotated_secret_reads_as_no_key(monkeypatch: pytest.MonkeyPatch) -> None label="y", provider="openai_compatible", base_url="http://h" ) stale.api_key_ciphertext = token - assert stale.api_key is None - assert stale.api_key_ciphertext is None + with pytest.raises(UnreadableSecretError): + _ = stale.api_key + assert stale.api_key_ciphertext == token diff --git a/surfsense_local/electron/build/icon.icns b/surfsense_local/electron/build/icon.icns deleted file mode 100644 index 6b46bc9cf4..0000000000 Binary files a/surfsense_local/electron/build/icon.icns and /dev/null differ diff --git a/surfsense_local/electron/build/icons/dev/icon-macos.png b/surfsense_local/electron/build/icons/dev/icon-macos.png new file mode 100644 index 0000000000..c582eabdd2 Binary files /dev/null and b/surfsense_local/electron/build/icons/dev/icon-macos.png differ diff --git a/surfsense_local/electron/build/icons/dev/icon.ico b/surfsense_local/electron/build/icons/dev/icon.ico new file mode 100644 index 0000000000..711a92b480 Binary files /dev/null and b/surfsense_local/electron/build/icons/dev/icon.ico differ diff --git a/surfsense_local/electron/build/icons/dev/icon.png b/surfsense_local/electron/build/icons/dev/icon.png new file mode 100644 index 0000000000..a4c056d865 Binary files /dev/null and b/surfsense_local/electron/build/icons/dev/icon.png differ diff --git a/surfsense_local/electron/build/icons/packaged/icon.icns b/surfsense_local/electron/build/icons/packaged/icon.icns new file mode 100644 index 0000000000..e84ac2b854 Binary files /dev/null and b/surfsense_local/electron/build/icons/packaged/icon.icns differ diff --git a/surfsense_local/electron/build/icon.ico b/surfsense_local/electron/build/icons/packaged/icon.ico similarity index 100% rename from surfsense_local/electron/build/icon.ico rename to surfsense_local/electron/build/icons/packaged/icon.ico diff --git a/surfsense_local/electron/build/icon.png b/surfsense_local/electron/build/icons/packaged/icon.png similarity index 100% rename from surfsense_local/electron/build/icon.png rename to surfsense_local/electron/build/icons/packaged/icon.png diff --git a/surfsense_local/electron/electron-builder.yml b/surfsense_local/electron/electron-builder.yml index 04d3438ed7..c5588ce5da 100644 --- a/surfsense_local/electron/electron-builder.yml +++ b/surfsense_local/electron/electron-builder.yml @@ -64,7 +64,7 @@ linux: target: [AppImage, deb] artifactName: "${productName}.${ext}" category: Office - icon: build/icon.png + icon: build/icons/packaged/icon.png executableName: surfsense-local desktop: entry: @@ -76,13 +76,13 @@ linux: win: target: [nsis] artifactName: "${productName}-Setup.${ext}" - icon: build/icon.ico + icon: build/icons/packaged/icon.ico mac: target: [dmg, zip] category: public.app-category.productivity - icon: build/icon.icns + icon: build/icons/packaged/icon.icns artifactName: "${productName}-${arch}.${ext}" hardenedRuntime: true gatekeeperAssess: false diff --git a/surfsense_local/electron/package.json b/surfsense_local/electron/package.json index c8c5b1b50f..669bc89310 100644 --- a/surfsense_local/electron/package.json +++ b/surfsense_local/electron/package.json @@ -1,5 +1,6 @@ { "name": "surfsense-local-electron", + "productName": "SurfSense", "private": true, "version": "2.0.2", "description": "Electron shell: spawns the API + worker sidecars, loads the Vite SPA", diff --git a/surfsense_local/electron/src/main/dev-app-identity.ts b/surfsense_local/electron/src/main/dev-app-identity.ts new file mode 100644 index 0000000000..90cb0fdb0e --- /dev/null +++ b/surfsense_local/electron/src/main/dev-app-identity.ts @@ -0,0 +1,47 @@ +import { join } from "node:path" + +import { app } from "electron" + +// Dev runs inside Electron's own bundle, so the Dock, taskbar and About panel +// would show Electron's name and icon. A "DEV" icon also keeps it apart from +// an installed SurfSense. Packaged builds get all of this from electron-builder. +const DEV_ICONS = join(__dirname, "../../build/icons/dev") +// Windows and Linux fill the canvas; the macOS Dock expects Apple's margin. +const DEV_ICON_PNG = join(DEV_ICONS, "icon.png") +const DEV_ICON_MACOS = join(DEV_ICONS, "icon-macos.png") +const DEV_NAME = "SurfSense Dev" + +// safeStorage names its keychain item after the app, so dev must not share +// "SurfSense": it would read, or recreate, the installed app's key. Call +// before anything touches safeStorage. +export function nameDevBuild(): void { + if (!app.isPackaged) app.setName(DEV_NAME) +} + +export function applyDevAppIdentity(): void { + if (app.isPackaged) return + if (process.platform === "darwin") { + app.dock?.setIcon(DEV_ICON_MACOS) + } + if (process.platform === "win32") { + // Its own taskbar group, not electron.exe's or the installed app's. + app.setAppUserModelId("com.surfsense.app.dev") + } + // iconPath is Linux and Windows only: macOS takes the panel's icon from the + // bundle, so it stays Electron's in dev. `version` replaces Electron's build + // number in the parentheses. + app.setAboutPanelOptions({ + applicationName: DEV_NAME, + applicationVersion: app.getVersion(), + version: app.getVersion(), + iconPath: DEV_ICON_PNG, + }) +} + +// Windows takes the taskbar icon from the window; Linux takes the window icon. +export function devWindowIcon(): { icon?: string } { + if (app.isPackaged || process.platform === "darwin") return {} + return { + icon: process.platform === "win32" ? join(DEV_ICONS, "icon.ico") : DEV_ICON_PNG, + } +} 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..435f93f4d9 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/app-locale.ts @@ -0,0 +1,20 @@ +import { app } from "electron" + +import { PSEUDO_LOCALE, type AppLocale } from "./locales.ts" +import { resolveLocale, type LocalePreference } from "./resolve-locale.ts" + +let current: AppLocale = "en" + +// Main owns the interface language; windows are told through IPC and +// translate it themselves. +export function applyLocalePreference(preference: LocalePreference): AppLocale { + current = + preference === PSEUDO_LOCALE + ? PSEUDO_LOCALE + : resolveLocale(preference, app.getPreferredSystemLanguages()) + return current +} + +export function currentLocale(): AppLocale { + 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..0501aa477d --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/locale-ipc.ts @@ -0,0 +1,30 @@ +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 +}): 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) + 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..5ac0827af0 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/locale-prefs.ts @@ -0,0 +1,43 @@ +import { readFileSync, renameSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import { app } from "electron" + +import { isLocale, PSEUDO_LOCALE } 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 { + if (value === PSEUDO_LOCALE) return !app.isPackaged + 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..37957a9081 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/locales.ts @@ -0,0 +1,13 @@ +// 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" + +// FormatJS's pseudo-locale, accepted only while the app is not packaged. +export const PSEUDO_LOCALE = "en-XA" +export type AppLocale = Locale | typeof PSEUDO_LOCALE + +export function isLocale(value: string): value is Locale { + return (LOCALES as readonly string[]).includes(value) +} 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..26f602eee7 --- /dev/null +++ b/surfsense_local/electron/src/main/i18n/resolve-locale.ts @@ -0,0 +1,22 @@ +import { + BASE_LOCALE, + isLocale, + type AppLocale, + type Locale, +} from "./locales.ts" + +export type LocalePreference = AppLocale | "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..b4a31a2c73 100644 --- a/surfsense_local/electron/src/main/index.ts +++ b/surfsense_local/electron/src/main/index.ts @@ -15,6 +15,11 @@ import { // undefined). require() honours it, and the getter is lazy so dev pays nothing. import { autoUpdater } from "electron-updater" +import { + applyDevAppIdentity, + devWindowIcon, + nameDevBuild, +} from "./dev-app-identity.ts" import { managedOriginalPath } from "./document-files.ts" import { getFreePort, waitForHealth } from "./net.ts" import { loadSecret } from "./secret.ts" @@ -56,6 +61,9 @@ import { type ThemePreference, } from "./theme-prefs.ts" import { loadWindowState, saveWindowState } from "./window-state.ts" +import { applyLocalePreference } 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" @@ -80,6 +88,8 @@ function openAllowedExternal(url: string): void { if (allowed) void shell.openExternal(allowed) } +nameDevBuild() + // Dev keeps its own dir so testing never leaks into the real install's ~/.surfsense. const DATA_DIR = join( app.getPath("home"), @@ -452,33 +462,21 @@ function applyBackgroundColorToAllWindows(theme: ThemePreference): void { for (const win of currentWindows()) win.setBackgroundColor(color) } -// Packaged only. Dev keeps Electron's default View menu (reload + DevTools). +// One menu in dev and packaged builds, all Electron roles: labels come from +// Electron and the OS, never the in-app language. DevTools only unpackaged. // https://www.electronjs.org/docs/latest/tutorial/application-menu -function installProductionMenu(): void { - if (!app.isPackaged) return - +function installMenu(): void { Menu.setApplicationMenu( Menu.buildFromTemplate([ ...(process.platform === "darwin" ? [{ role: "appMenu" as const }] : []), { role: "fileMenu" }, { role: "editMenu" }, { - label: "View", + role: "viewMenu", submenu: [ - { - label: "Reload", - click: (_item, win) => { - if (win instanceof BrowserWindow) win.reload() - }, - }, - { - label: "Force Reload", - click: (_item, win) => { - if (win instanceof BrowserWindow) { - win.webContents.reloadIgnoringCache() - } - }, - }, + { role: "reload" }, + { role: "forceReload" }, + ...(app.isPackaged ? [] : [{ role: "toggleDevTools" as const }]), { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, @@ -496,6 +494,7 @@ function createWindow(apiUrl: string): void { const savedState = app.isPackaged ? loadWindowState() : null const win = new BrowserWindow({ ...(savedState?.bounds ?? { width: 1280, height: 800 }), + ...devWindowIcon(), backgroundColor: resolveBackgroundColor(loadThemePreference()), show: false, // https://www.electronjs.org/docs/latest/tutorial/custom-title-bar @@ -576,9 +575,15 @@ function main(): void { app .whenReady() .then(async () => { + applyLocalePreference(loadLocalePreference()) + applyDevAppIdentity() const boot = await bootSidecars() registerDocumentHandlers(boot.dataDir) - installProductionMenu() + registerLocaleHandlers({ + isTrusted: (sender) => + mainWindow !== null && sender === mainWindow.webContents, + }) + installMenu() createWindow(boot.apiUrl) await registerUpdateHandlers() app.on("activate", () => { 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"] } 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..3c0b83e219 100644 --- a/surfsense_local/frontend/eslint.config.js +++ b/surfsense_local/frontend/eslint.config.js @@ -3,10 +3,11 @@ import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' +import formatjs from 'eslint-plugin-formatjs' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'src/i18n/compiled']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -23,4 +24,36 @@ export default defineConfig([ files: ['src/components/ui/**'], rules: { 'react-refresh/only-export-components': 'off' }, }, + { + // Interface text: every call carries its English for `formatjs extract`, + // passes every placeholder, and uses a __ id. + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/**/*.test.{ts,tsx}'], + plugins: { formatjs }, + rules: { + 'formatjs/enforce-default-message': ['error', 'literal'], + 'formatjs/enforce-placeholders': 'error', + 'formatjs/enforce-id': [ + 'error', + { + idInterpolationPattern: '[sha512:contenthash:base64:6]', + idWhitelist: [ + '^[a-z0-9]+_([a-z0-9]+_)*(title|body|label|placeholder|button|tooltip|empty|error|toast|aria|link|status)$', + '^[a-z0-9]+_error_[a-z0-9_]+$', + ], + }, + ], + }, + }, + { + // 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/index.html b/surfsense_local/frontend/index.html index b0dba5c141..7457eaabaa 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 } @@ -31,7 +34,7 @@ - SurfSense Community Local + SurfSense
diff --git a/surfsense_local/frontend/package.json b/surfsense_local/frontend/package.json index f289638f07..9bb2f3bcf9 100644 --- a/surfsense_local/frontend/package.json +++ b/surfsense_local/frontend/package.json @@ -4,12 +4,16 @@ "version": "2.0.2", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", + "dev": "pnpm translations && pnpm translations:pseudo && vite", + "translations": "pnpm translations:extract && pnpm translations:compile", + "translations:extract": "formatjs extract \"src/**/*.{ts,tsx}\" --ignore \"**/*.test.*\" --format simple --out-file translations/en.json", + "translations:compile": "formatjs compile-folder translations src/i18n/compiled --format simple --ast", + "translations:pseudo": "formatjs compile translations/en.json --format simple --ast --pseudo-locale en-XA --out-file src/i18n/compiled/en-XA.json", + "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 +42,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 +53,8 @@ }, "devDependencies": { "@eslint/js": "^10", + "@formatjs/cli": "6.16.32", + "@formatjs/unplugin": "1.2.12", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/node": "^24", @@ -55,6 +62,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", "eslint": "^10", + "eslint-plugin-formatjs": "8.1.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17", diff --git a/surfsense_local/frontend/pnpm-lock.yaml b/surfsense_local/frontend/pnpm-lock.yaml index 390fe9b26a..2c38e4491d 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,12 @@ 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 + '@formatjs/unplugin': + specifier: 1.2.12 + version: 1.2.12(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1)) '@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) @@ -129,6 +138,9 @@ importers: eslint: specifier: ^10 version: 10.9.1(jiti@2.7.0) + eslint-plugin-formatjs: + specifier: 8.1.0 + version: 8.1.0(eslint@10.9.1(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@10.9.1(jiti@2.7.0)) @@ -395,6 +407,9 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bazel/runfiles@6.5.0': + resolution: {integrity: sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -520,6 +535,109 @@ 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==} + + '@formatjs/ts-transformer@4.4.22': + resolution: {integrity: sha512-xbRC3Cq+7UmCmA1thwvCnaZuJKay47tZ9uDi6IMA5LLxjXE7pluPJZ/uMr+7UlthxEsMKjleY1p1ifBJNhlTYA==} + engines: {node: '>= 20.12.0'} + peerDependencies: + ts-jest: '29' + peerDependenciesMeta: + ts-jest: + optional: true + + '@formatjs/unplugin@1.2.12': + resolution: {integrity: sha512-0ljT/XE/wrvxSHB0pyGsbVvxf21nwb6914mPPE2pO437X198T0QV4uOG0avSbdaE8n80HiuqTKB5I4nEPl2rsA==} + peerDependencies: + '@rspack/core': '>=1' + esbuild: '>=0.17' + rollup: '>=3' + vite: '>=5' + webpack: '5' + peerDependenciesMeta: + '@rspack/core': + optional: true + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + '@gera2ld/jsx-dom@2.2.2': resolution: {integrity: sha512-EOqf31IATRE6zS1W1EoWmXZhGfLAoO9FIlwTtHduSrBdud4npYBxYAkv8dZ5hudDPwJeeSjn40kbCL4wAzr8dA==} @@ -670,9 +788,134 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-parser/binding-android-arm-eabi@0.151.0': + resolution: {integrity: sha512-QLfrdT2XmHMo8krBiKHtnTtU2ugxruwnIWMcIl5qpCRV9KPZk1s2YZzSHkIjzabLxG6/1yOoDLvqfS3TrJhfWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.151.0': + resolution: {integrity: sha512-APzs9QxkLGR85I6Slb93Onsd+9ZdR+PdKIy0g8oOuipiBRtXbEE3PW1QpRn3O2C9dCsfAC8+t7dWGRyByiTqJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.151.0': + resolution: {integrity: sha512-uPj085g+2TcYKm4LrPOF6mU/ilb48ghAfLMA+YIxaSAasgDAXO0c5UMjRrB9L59A1aMU9fvPtCGoW9cov9UtFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.151.0': + resolution: {integrity: sha512-gqOyGZsEX9r8i37ONL2X8heUKCbTAY3UvaWlEJKPJYdgaYoIAXSuZrG27legT+0e4m0X/53RupoH6lfbxtMiMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.151.0': + resolution: {integrity: sha512-Uih9lYFoB9k87b8jZ2263b8/HC0AuCQo9qPfu14KjoDmC7AmGvo4pCLiuEVVHjQycUjTO2dNtG39MIGUuCOx3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.151.0': + resolution: {integrity: sha512-BW9ryFV0V7h37xAEpb1rx+jI/ArFjXlSvJ2CaPwqxCj+kcJdDMvt5/PpWLRRk56UpNpxpq/5+puTUd0/4OPCZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.151.0': + resolution: {integrity: sha512-68zJuPgiFpZ9FF5WgpJiE9cukBZVNv79Yod2VV/wJLaiws+abCEfcxwVEH0608a7E/0zOO1Pj2iY7UrnUS8ydg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.151.0': + resolution: {integrity: sha512-s/CCfZQcqoRo4Ij7ML43Yhe+gJZU7zmudd6StA0T9WDFPLaiWMoBlPRmoS39jPpODxPwI/6yPoiyjKOMNzSqCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.151.0': + resolution: {integrity: sha512-QSjn7WWZNGDTRlMT0SUGDHZNI9XOhGXWdqhL/FuDR/nh0MOqGQT8OHGBy95mZHne4WvoS0OgnUl/+55u07yBiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.151.0': + resolution: {integrity: sha512-RnvhC+cOND1dsNqbsirtZa9IWVJCCnxIZxs7nAvW0ZFjqqofwKbR3+TNKPqx2eASRC6+b78rZMLiNbDeDUp8+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.151.0': + resolution: {integrity: sha512-fpwItfDNVDgXOMV6GdMHD3QV3NdYWhwDH0E8BRuYNeQyUKchb8sNLvAQg+g1HxENvrYjiLqLWAafOja44/sLig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.151.0': + resolution: {integrity: sha512-fKchmw5aUlZem7uZZfxsQAl53cFiGWrAKhwjYxvJFiBPQyC9CZ2P7Qhv9unDJSezChu6iJ6XGQWQnRUyLp7j5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.151.0': + resolution: {integrity: sha512-vDtxikQV1AixMY5pFAW5tDtGHmoexjGpEgtmPBAF3tXvEubm9KucxV0CxtuAIGEZ1aGyvZ74Hfryb7Vvq52F3Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.151.0': + resolution: {integrity: sha512-JHB/TuPCUdnkMq9RXB7C/SZuauJUp1EYHi5DaH7ge7uNlCY4AOhc0QeWOjo2rj308+NjxgTwminglWpqlllAig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.151.0': + resolution: {integrity: sha512-rkoZa4Wv5LbUHdCJ+3vC9G68r49gaoaQPg52Jx7+aC60q3QHIiysrDHQlQ3hiIevsmDl0RgzD+Y389q/hb34WA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.151.0': + resolution: {integrity: sha512-zp74XxS9WHSp6ct8LEoTa87F5pJOjFPG0o7dg2YCXaKo0raxabpFMC7NTbefIzWmF9KLIuJF/Rg6CPIu6P/Aaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.151.0': + resolution: {integrity: sha512-xtqapASyiSTv6rdm29kKFoDsxTo1oCmMfhPLGCDbZQ0Y6sU4m7IPEH05NgNEs2SrTErBuldkvS6e0Aqzmu0BgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.151.0': + resolution: {integrity: sha512-9s4L40gxkolXQx1Q675XEZqgI4j+7+os4kZAFHdrDQVapjB9J2iodub7A8VAZ0cGTfhZNLH4D9L26fwEyqu3fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.151.0': + resolution: {integrity: sha512-temy3DJf08taii88l13vfs5Om/HWECLSAj5Ca7ANMvocrekAgCuPC4GeHLi0mJ0cmGPznfeaaj80wqQoPIRYPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@paper-design/shaders-react@0.0.80': resolution: {integrity: sha512-Y1oxUeh5D2AECo05O+4UYRbaMfQzKKYH3Q5jcvirOcoX0hTPYbezCLn5g94nvcuzxy5LN/uqwsL3jDUp8r3E+A==} peerDependencies: @@ -1648,6 +1891,18 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1687,6 +1942,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/react-dom@19.2.7': resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: @@ -1766,6 +2024,9 @@ packages: '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@unicode/unicode-17.0.0@2.0.7': + resolution: {integrity: sha512-XdCUsw7exiSD8QICrzwb99xx9O/e7qMx/Oew1e8MBQGavCIIR0VO6vID7OSH/wQjUjogcfydqmsuRLkGM6LfLA==} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1988,6 +2249,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -2330,6 +2595,10 @@ packages: resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} @@ -2476,6 +2745,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-formatjs@8.1.0: + resolution: {integrity: sha512-pjnhJCkjl0Ly6NXBwiNcRAA7Ttf0slV7tScuBnxM1IzUD1Iox0vOuZDBt46NbneoQP7nG8m/gN077n2PZuLrEQ==} + peerDependencies: + eslint: 9 || 10 + eslint-plugin-react-hooks@7.1.1: resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} @@ -2737,6 +3011,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2870,6 +3147,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'} @@ -2977,6 +3257,9 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3033,6 +3316,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stable-stringify@1.3.0: + resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} + engines: {node: '>= 0.4'} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3041,6 +3328,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonify@0.0.1: + resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jszip@3.10.2: resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} @@ -3599,6 +3889,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + object-treeify@1.1.33: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} @@ -3644,6 +3938,10 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + oxc-parser@0.151.0: + resolution: {integrity: sha512-cX++XpBDCtSe52KKH3rhbh6xIRidYPpyB2reI3azD4l+Y0ci4JZiUgVXyM/r9rrMnYZf9gdWi+9fvFl1wol7nA==} + engines: {node: ^20.19.0 || >=22.12.0} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -3900,6 +4198,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==} @@ -4088,6 +4392,10 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -4405,6 +4713,42 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@3.4.0: + resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rsbuild/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rsbuild/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -4584,6 +4928,9 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -5001,6 +5348,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bazel/runfiles@6.5.0': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -5128,6 +5477,75 @@ 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 + + '@formatjs/ts-transformer@4.4.22': + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.20 + '@types/babel__core': 7.20.5 + '@types/node': 24.13.3 + json-stable-stringify: 1.3.0 + typescript: 6.0.3 + + '@formatjs/unplugin@1.2.12(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1))': + dependencies: + '@bazel/runfiles': 6.5.0 + '@formatjs/icu-messageformat-parser': 3.5.20 + '@formatjs/ts-transformer': 4.4.22 + '@types/node': 24.13.3 + decode-named-character-reference: 1.3.0 + magic-string: 1.2.3 + oxc-parser: 0.151.0 + unplugin: 3.4.0(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1)) + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1) + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - bun-types-no-globals + - rolldown + - ts-jest + - unloader + '@gera2ld/jsx-dom@2.2.2': dependencies: '@babel/runtime': 7.29.7 @@ -5259,8 +5677,67 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@oxc-parser/binding-android-arm-eabi@0.151.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.151.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.151.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.151.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.151.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.151.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.151.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.151.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.151.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.151.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.151.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.151.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.151.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.151.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.151.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.151.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.151.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.151.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.151.0': + optional: true + '@oxc-project/types@0.148.0': {} + '@oxc-project/types@0.151.0': {} + '@paper-design/shaders-react@0.0.80(@types/react@19.2.18)(react@19.2.8)': dependencies: '@paper-design/shaders': 0.0.80 @@ -6225,6 +6702,27 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6264,6 +6762,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/picomatch@4.0.3': {} + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -6371,6 +6871,8 @@ snapshots: '@ungap/structured-clone@1.4.0': {} + '@unicode/unicode-17.0.0@2.0.7': {} + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -6593,6 +7095,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6939,6 +7448,12 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + define-lazy-prop@2.0.0: {} define-lazy-prop@3.0.0: {} @@ -7063,6 +7578,19 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-plugin-formatjs@8.1.0(eslint@10.9.1(jiti@2.7.0)): + dependencies: + '@formatjs/icu-messageformat-parser': 3.5.20 + '@formatjs/ts-transformer': 4.4.22 + '@types/estree-jsx': 1.0.5 + '@types/picomatch': 4.0.3 + '@unicode/unicode-17.0.0': 2.0.7 + eslint: 10.9.1(jiti@2.7.0) + magic-string: 1.2.3 + picomatch: 4.0.7 + transitivePeerDependencies: + - ts-jest + eslint-plugin-react-hooks@7.1.1(eslint@10.9.1(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 @@ -7398,6 +7926,10 @@ snapshots: graceful-fs@4.2.11: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + has-symbols@1.1.0: {} hasown@2.0.4: @@ -7603,6 +8135,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: {} @@ -7670,6 +8207,8 @@ snapshots: isarray@1.0.0: {} + isarray@2.0.5: {} + isexe@2.0.0: {} isexe@3.1.5: {} @@ -7726,6 +8265,14 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stable-stringify@1.3.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + isarray: 2.0.5 + jsonify: 0.0.1 + object-keys: 1.1.1 + json5@2.2.3: {} jsonfile@6.2.1: @@ -7734,6 +8281,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonify@0.0.1: {} + jszip@3.10.2: dependencies: lie: 3.3.0 @@ -8441,6 +8990,8 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + object-treeify@1.1.33: {} obug@2.1.4: {} @@ -8505,6 +9056,30 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + oxc-parser@0.151.0: + dependencies: + '@oxc-project/types': 0.151.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.151.0 + '@oxc-parser/binding-android-arm64': 0.151.0 + '@oxc-parser/binding-darwin-arm64': 0.151.0 + '@oxc-parser/binding-darwin-x64': 0.151.0 + '@oxc-parser/binding-freebsd-x64': 0.151.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.151.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.151.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.151.0 + '@oxc-parser/binding-linux-arm64-musl': 0.151.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.151.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.151.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.151.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.151.0 + '@oxc-parser/binding-linux-x64-gnu': 0.151.0 + '@oxc-parser/binding-linux-x64-musl': 0.151.0 + '@oxc-parser/binding-openharmony-arm64': 0.151.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.151.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.151.0 + '@oxc-parser/binding-win32-x64-msvc': 0.151.0 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -8740,6 +9315,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): @@ -9011,6 +9594,15 @@ snapshots: transitivePeerDependencies: - supports-color + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} setprototypeof@1.2.0: {} @@ -9370,6 +9962,15 @@ snapshots: unpipe@1.0.0: {} + unplugin@3.4.0(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.7 + vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.1) + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -9493,6 +10094,8 @@ snapshots: webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 diff --git a/surfsense_local/frontend/pnpm-workspace.yaml b/surfsense_local/frontend/pnpm-workspace.yaml index 6afd1d41d7..2879bd1610 100644 --- a/surfsense_local/frontend/pnpm-workspace.yaml +++ b/surfsense_local/frontend/pnpm-workspace.yaml @@ -3,3 +3,14 @@ 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' + - eslint-plugin-formatjs@8.1.0 diff --git a/surfsense_local/frontend/src/app/app-bootstrap.test.tsx b/surfsense_local/frontend/src/app/app-bootstrap.test.tsx index 6984ee7cd8..019906524d 100644 --- a/surfsense_local/frontend/src/app/app-bootstrap.test.tsx +++ b/surfsense_local/frontend/src/app/app-bootstrap.test.tsx @@ -1,7 +1,9 @@ -import { cleanup, screen } from "@testing-library/react" +import { cleanup, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { TooltipProvider } from "@/components/ui/tooltip" +import { EgressPrompt } from "@/features/egress/egress-prompt" import { render } from "@/test-utils" import { AppBootstrap } from "./app-bootstrap" @@ -154,11 +156,261 @@ describe("app bootstrap", () => { expect(setup.className).toContain("text-white") const message = screen.getByRole("textbox", { name: "Message" }) expect((message as HTMLTextAreaElement).disabled).toBe(true) - expect(message.getAttribute("placeholder")).toBe("Follow up on this answer") - expect( - screen.getByText("SurfSense can make mistakes. Check important answers.") - ).toBeTruthy() + // No chat remembered from before, so launch opens a new one, centered. + expect(message.getAttribute("placeholder")).toBe( + "Turn your sources into answers" + ) expect(screen.queryByText("No chat model is available")).toBeNull() expect(screen.queryByText("Choose your AI model")).toBeNull() }, 15_000) + + // A completed install whose saved chat model is anthropic/claude-fable-5 on + // connection 1, and whose check of that model answers `models`. + function launchWithSavedModel( + models: () => Response, + other: (path: string, init?: RequestInit) => Response | null = () => null + ) { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input) + const answered = other(path, init) + if (answered) return answered + if (path === "/llm/onboarding") { + return Response.json({ completed: true }) + } + if (path === "/llm/selection/text_gen") { + return Response.json({ + model_type: "text_gen", + provider: "openai_compatible", + connection_id: 1, + name: "anthropic/claude-fable-5", + updated_at: "2026-09-24T00:00:00Z", + }) + } + if (path === "/llm/connections/1/models") { + return models() + } + if (path === "/workspaces") { + return Response.json([ + { + id: 1, + name: "Research", + created_at: "2026-09-09T00:00:00Z", + updated_at: "2026-09-09T00:00:00Z", + }, + ]) + } + if ( + path === "/workspaces/1/chat/threads" || + path === + "/workspaces/1/documents?document_type=FILE&document_type=NOTE" + ) { + return Response.json([]) + } + return Response.json({ detail: "not found" }, { status: 404 }) + }) + ) + render( + + + + + ) + } + + it.each([ + { + status: 409, + code: "unreadable_secret", + reason: + "Couldn’t use anthropic/claude-fable-5, its saved key has to be entered again.", + }, + { + status: 502, + code: "provider_unreachable", + reason: "Couldn’t reach the server for anthropic/claude-fable-5.", + }, + ])( + "keeps the saved model, holds the composer and says why when its check fails with $code", + async ({ status, code, reason }) => { + // A key saved under a lost keychain secret, or a remote endpoint that is + // offline, must hold that model, not the whole app, and keep it saved so + // it can be checked again once fixed. + launchWithSavedModel(() => + Response.json({ detail: { code, message: "failed" } }, { status }) + ) + + const reasonText = await screen.findByText( + reason, + {}, + { timeout: 10_000 } + ) + expect(screen.queryByRole("button", { name: "Set up model" })).toBeNull() + expect(screen.getByText("anthropic/claude-fable-5")).toBeTruthy() + expect( + screen.getByRole("textbox", { name: "Message" }) + .disabled + ).toBe(true) + const notice = reasonText.closest("[role=status]")! + expect( + within(notice).getByRole("button", { name: "Open settings" }) + ).toBeTruthy() + expect(screen.queryByText("SurfSense could not start")).toBeNull() + }, + 15_000 + ) + + it("keeps the model when sending to its host is off, and asks before any send", async () => { + // Egress off is a consent not yet given, not a broken model. The question is + // put by the notice, when the user chooses to answer it, never by a send. + let allowed = false + launchWithSavedModel( + () => + allowed + ? Response.json([ + { + connection_id: 1, + connection_label: "OpenRouter", + name: "anthropic/claude-fable-5", + types: ["text_gen"], + capability_source: "catalog", + selectable_for: ["text_gen"], + }, + ]) + : Response.json( + { + detail: { + code: "egress_disabled", + message: + "sending data to openrouter.ai is off in Settings > Network", + destination: "host:openrouter.ai", + host: "openrouter.ai", + }, + }, + { status: 403 } + ), + (path, init) => { + if (path === "/egress/host:openrouter.ai" && init?.method === "PUT") { + allowed = true + return Response.json({ + destination: "host:openrouter.ai", + enabled: true, + }) + } + return null + } + ) + + const reason = await screen.findByText( + "Sending data to openrouter.ai is off.", + {}, + { timeout: 10_000 } + ) + const notice = reason.closest("[role=status]")! + expect(screen.queryByRole("button", { name: "Set up model" })).toBeNull() + const message = screen.getByRole("textbox", { + name: "Message", + }) + expect(message.disabled).toBe(true) + expect(message.getAttribute("placeholder")).toBe( + "Allow sending to openrouter.ai to chat" + ) + + await userEvent.click( + within(notice).getByRole("button", { name: "Allow…" }) + ) + const consent = await screen.findByRole("alertdialog") + await userEvent.click( + within(consent).getByRole("button", { name: "Allow" }) + ) + + await waitFor(() => + expect( + screen.queryByText("Sending data to openrouter.ai is off.") + ).toBeNull() + ) + expect(message.disabled).toBe(false) + }, 15_000) + + it("sends the user to Network, not the model list, when egress is off", async () => { + launchWithSavedModel(() => + Response.json( + { + detail: { + code: "egress_disabled", + message: + "sending data to openrouter.ai is off in Settings > Network", + destination: "host:openrouter.ai", + host: "openrouter.ai", + }, + }, + { status: 403 } + ) + ) + + const reason = await screen.findByText( + "Sending data to openrouter.ai is off.", + {}, + { timeout: 10_000 } + ) + await userEvent.click( + within(reason.closest("[role=status]")!).getByRole( + "button", + { name: "Open settings" } + ) + ) + const settings = await screen.findByRole("dialog") + expect( + within(settings).getByRole("heading", { name: "Network" }) + ).toBeTruthy() + }, 15_000) + it("clears the notice once the key is fixed in Settings, without a reload", async () => { + // Settings is where a key is entered again; closing it must be enough. + let keyReadable = false + launchWithSavedModel(() => + keyReadable + ? Response.json([ + { + connection_id: 1, + connection_label: "OpenRouter", + name: "anthropic/claude-fable-5", + types: ["text_gen"], + capability_source: "catalog", + selectable_for: ["text_gen"], + }, + ]) + : Response.json( + { detail: { code: "unreadable_secret", message: "failed" } }, + { status: 409 } + ) + ) + const unreadable = + "Couldn’t use anthropic/claude-fable-5, its saved key has to be entered again." + + const reason = await screen.findByText(unreadable, {}, { timeout: 10_000 }) + await userEvent.click( + within(reason.closest("[role=status]")!).getByRole( + "button", + { name: "Open settings" } + ) + ) + await screen.findByRole("dialog") + keyReadable = true + await userEvent.keyboard("{Escape}") + + await waitFor(() => expect(screen.queryByText(unreadable)).toBeNull()) + const message = screen.getByRole("textbox", { + name: "Message", + }) + expect(message.disabled).toBe(false) + }, 15_000) }) diff --git a/surfsense_local/frontend/src/app/app-bootstrap.tsx b/surfsense_local/frontend/src/app/app-bootstrap.tsx index 11f72fa112..31129496e7 100644 --- a/surfsense_local/frontend/src/app/app-bootstrap.tsx +++ b/surfsense_local/frontend/src/app/app-bootstrap.tsx @@ -3,14 +3,17 @@ import { useEffect, useState } from "react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { ServerOffIcon } from "@/components/ui/icons" -import { getProviderModels } from "@/features/models/chat-candidates/api" -import { getConnectionModels } from "@/features/models/remote/models/api" import { getGenerationSelection, type ModelSelection, } from "@/features/models/selection/api" +import { + checkAvailability, + type Availability, +} from "@/features/models/selection/availability" import { getOnboardingStatus } from "@/features/onboarding/api" import { OnboardingPage } from "@/features/onboarding/onboarding-page" +import { intl } from "@/i18n/intl" import { listWorkspaces, type Workspace } from "@/features/workspaces/api" @@ -24,14 +27,21 @@ type BootstrapState = status: "ready" /** Loaded before the state turns ready, so it renders without suspending. */ Dashboard: DashboardComponent + // The saved choice, kept whatever its status: clearing it to say it + // can't be used would leave nothing to check again once it can. selection: ModelSelection | null - providerAvailable: boolean + availability: Availability workspaces: Workspace[] } | { status: "error"; message: string } function messageFrom(error: unknown) { - return error instanceof Error ? error.message : "An unexpected error occurred" + return error instanceof Error + ? error.message + : intl.formatMessage({ + id: "app_bootstrap_unexpected_error", + defaultMessage: "An unexpected error occurred", + }) } async function fetchBootstrapState(): Promise { @@ -49,31 +59,16 @@ async function fetchBootstrapState(): Promise { getGenerationSelection(), listWorkspaces(), ]) - let currentSelection: ModelSelection | null = null - if (selection?.provider === "openai_compatible") { - const models = - selection.connection_id === null - ? [] - : await getConnectionModels(selection.connection_id) - currentSelection = models.some((model) => model.name === selection.name) - ? selection - : null - } else if (selection) { - const models = await getProviderModels(selection.provider) - currentSelection = models.some( - (model) => - model.installed && - model.selectable_for.includes("text_gen") && - model.name === selection.name - ) - ? selection - : null - } + // Never fails startup: a model that can't be used opens the app without + // it, and the dashboard says why. + const availability: Availability = selection + ? await checkAvailability(selection) + : { status: "gone" } return { status: "ready", Dashboard: await dashboard, - selection: currentSelection, - providerAvailable: currentSelection !== null, + selection, + availability, workspaces, } } catch (error) { @@ -86,7 +81,10 @@ function GlobalLoader() {
@@ -121,7 +119,7 @@ export function AppBootstrap() { status: "ready", Dashboard, selection, - providerAvailable: true, + availability: { status: "available" }, workspaces, }) ) @@ -135,10 +133,15 @@ export function AppBootstrap() { if (state.status === "error") { return ( -
+
- SurfSense could not start + + {intl.formatMessage({ + id: "app_bootstrap_start_failed_title", + defaultMessage: "SurfSense could not start", + })} +

{state.message}

@@ -163,7 +169,7 @@ export function AppBootstrap() { return ( setState((current) => diff --git a/surfsense_local/frontend/src/components/relative-time.tsx b/surfsense_local/frontend/src/components/relative-time.tsx index 24485b3042..1c6102837d 100644 --- a/surfsense_local/frontend/src/components/relative-time.tsx +++ b/surfsense_local/frontend/src/components/relative-time.tsx @@ -5,12 +5,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" +import { intl } from "@/i18n/intl" import { cn } from "@/lib/utils" -const formatter = new Intl.RelativeTimeFormat(undefined, { - numeric: "auto", - style: "long", -}) const units: [number, Intl.RelativeTimeFormatUnit][] = [ [60, "minute"], [24, "hour"], @@ -21,13 +18,73 @@ const units: [number, Intl.RelativeTimeFormatUnit][] = [ ] // Sidebar rows: "5m", "2h", "3d", "2w", "4mo", "1y"; the tooltip has the date. -const compactUnits: [number, string][] = [ - [60, "m"], - [24, "h"], - [7, "d"], - [4.345, "w"], - [12, "mo"], - [Number.POSITIVE_INFINITY, "y"], +const compactUnits: [number, (count: number) => string][] = [ + [ + 60, + (count) => + intl.formatMessage( + { + id: "app_relative_time_compact_minutes_label", + defaultMessage: "{count, number}m", + }, + { count } + ), + ], + [ + 24, + (count) => + intl.formatMessage( + { + id: "app_relative_time_compact_hours_label", + defaultMessage: "{count, number}h", + }, + { count } + ), + ], + [ + 7, + (count) => + intl.formatMessage( + { + id: "app_relative_time_compact_days_label", + defaultMessage: "{count, number}d", + }, + { count } + ), + ], + [ + 4.345, + (count) => + intl.formatMessage( + { + id: "app_relative_time_compact_weeks_label", + defaultMessage: "{count, number}w", + }, + { count } + ), + ], + [ + 12, + (count) => + intl.formatMessage( + { + id: "app_relative_time_compact_months_label", + defaultMessage: "{count, number}mo", + }, + { count } + ), + ], + [ + Number.POSITIVE_INFINITY, + (count) => + intl.formatMessage( + { + id: "app_relative_time_compact_years_label", + defaultMessage: "{count, number}y", + }, + { count } + ), + ], ] // Only triggers re-renders every 10s; never caches the time itself. @@ -61,13 +118,19 @@ function getSnapshot() { function formatRelativeTime(date: Date, currentTime: number) { const seconds = (date.getTime() - currentTime) / 1000 if (Math.abs(seconds) < 60) { - return "just now" + return intl.formatMessage({ + id: "app_relative_time_just_now_label", + defaultMessage: "just now", + }) } let value = seconds / 60 for (const [limit, unit] of units) { if (Math.abs(value) < limit) { - return formatter.format(Math.round(value), unit) + return intl.formatRelativeTime(Math.round(value), unit, { + numeric: "auto", + style: "long", + }) } value /= limit } @@ -76,22 +139,27 @@ function formatRelativeTime(date: Date, currentTime: number) { function formatCompactTime(date: Date, currentTime: number) { const seconds = Math.max(0, (currentTime - date.getTime()) / 1000) if (seconds < 60) { - return "now" + return intl.formatMessage({ + id: "app_relative_time_compact_now_label", + defaultMessage: "now", + }) } let value = seconds / 60 - for (const [limit, unit] of compactUnits) { - if (value < limit) return `${Math.max(1, Math.floor(value))}${unit}` + for (const [limit, label] of compactUnits) { + if (value < limit) return label(Math.max(1, Math.floor(value))) value /= limit } } export function RelativeTime({ + id, date, compact = false, showTooltip = true, className, }: { + id?: string date: Date compact?: boolean showTooltip?: boolean @@ -99,17 +167,17 @@ export function RelativeTime({ }) { const currentTime = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) * 1000 - const exactTime = date.toLocaleString("en-US", { + const exactTime = intl.formatDate(date, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", - hour12: true, }) const time = (