From fe4dce88dc6ea88b9a6d6b5b539d4080e580b392 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sun, 2 Aug 2026 06:56:39 -0400 Subject: [PATCH 1/4] Add i18n tooling and localize UI strings Introduce localization infrastructure and apply it across the UI. Adds build/i18n scripts (resources.py, translate.py, check.py, pseudo.py) and documentation (README.md, glossary.md) to discover, generate, check, and machine-translate resource files (including a pseudo-locale). Add resource files and helpers (Plural.cs, UI.resx, UI.de.resx, UI.qps-Ploc.resx, VersoCultures, VersoLocalization, LanguageOption) and editor localization bundles (vscode l10n files and package.nls.json variants). Replace many hard-coded English strings in Blazor components with UI resource lookups and wire up localization in the host/CLI/VSCode pieces. Also add tests for localization and related changes to project files and VSCode extension code. Signed-off-by: Torrey Betts --- build/i18n/README.md | 112 ++ build/i18n/check.py | 133 ++ build/i18n/glossary.md | 67 + build/i18n/pseudo.py | 74 + build/i18n/resources.py | 260 ++++ build/i18n/translate.py | 220 +++ .../Components/CustomLayoutFrame.razor | 27 +- .../Components/Notebook/Cell.razor | 44 +- .../Notebook/CellPropertiesPanel.razor | 8 +- .../Components/Notebook/ComparePanel.razor | 52 +- .../Components/Notebook/DefaultCellList.razor | 2 +- .../Components/Notebook/DiffErrorDialog.razor | 10 +- .../Notebook/ExtensionConsentDialog.razor | 20 +- .../Components/Notebook/ExtensionPanel.razor | 182 ++- .../Notebook/ExtensionPanelHost.razor | 4 +- .../Components/Notebook/MetadataPanel.razor | 16 +- .../Notebook/NotebookDiffView.razor | 69 +- .../Components/Notebook/OutputView.razor | 29 +- .../Notebook/PropertyFieldComponent.razor | 4 +- .../Components/Notebook/SettingsPanel.razor | 8 +- .../Components/Notebook/Toolbar.razor | 63 +- .../Notebook/VariableExplorer.razor | 12 +- .../Components/Notebook/ViewPanel.razor | 6 +- .../Components/Notebook/WidgetFrame.razor | 2 +- .../Components/_Imports.razor | 1 + src/Verso.Blazor.Shared/Models/DiffSources.cs | 78 ++ src/Verso.Blazor.Shared/Models/HostPanels.cs | 37 +- .../Models/PanelDisplayNames.cs | 18 +- src/Verso.Blazor.Shared/Resources/Plural.cs | 24 + src/Verso.Blazor.Shared/Resources/UI.de.resx | 79 ++ .../Resources/UI.qps-Ploc.resx | 922 +++++++++++++ src/Verso.Blazor.Shared/Resources/UI.resx | 1209 +++++++++++++++++ .../Verso.Blazor.Shared.csproj | 23 + .../Pages/NotebookPage.razor | 58 +- src/Verso.Blazor.Wasm/Program.cs | 4 + .../Services/RemoteNotebookService.cs | 50 +- src/Verso.Blazor.Wasm/_Imports.razor | 1 + src/Verso.Blazor.Wasm/wwwroot/index.html | 15 +- src/Verso.Blazor/Components/App.razor | 24 +- .../Components/DiffSourcePickerDialog.razor | 14 +- .../Components/NotebookInputDialog.razor | 10 +- .../Components/Pages/NotebookPage.razor | 99 +- src/Verso.Blazor/Components/Routes.razor | 4 +- src/Verso.Blazor/Components/_Imports.razor | 1 + .../Localization/VersoLocalization.cs | 62 + src/Verso.Blazor/Program.cs | 6 + src/Verso.Blazor/Services/GitCliHelper.cs | 14 +- .../Services/ServerNotebookService.Diff.cs | 42 +- .../ServerNotebookService.Marketplace.cs | 7 +- .../Services/ServerNotebookService.cs | 4 +- src/Verso.Cli/Commands/InfoCommand.cs | 1 + src/Verso.Cli/Commands/ServeCommand.cs | 4 +- src/Verso.Cli/Hosting/BlazorHostBuilder.cs | 10 + src/Verso.Cli/Program.cs | 7 + src/Verso.Cli/Utilities/LanguageOption.cs | 43 + src/Verso.Host/Handlers/NotebookHandler.cs | 7 +- src/Verso.Host/Program.cs | 6 + .../Extensions/CellDisplayPropertyProvider.cs | 25 +- .../Extensions/CellTypes/HtmlCellType.cs | 9 +- .../Extensions/CellTypes/MarkdownCellType.cs | 6 +- .../Extensions/CellTypes/MermaidCellType.cs | 9 +- .../CellTypes/ParametersCellType.cs | 7 +- .../CellVisibilityPropertyProvider.cs | 51 +- src/Verso/Extensions/Kernels/HtmlKernel.cs | 6 +- src/Verso/Extensions/Kernels/MermaidKernel.cs | 6 +- .../Layouts/ContentFallbackRenderer.cs | 5 +- .../Extensions/Layouts/DashboardLayout.cs | 16 +- .../Extensions/Layouts/NotebookLayout.cs | 29 +- .../Extensions/Layouts/PresentationLayout.cs | 7 +- .../Extensions/Renderers/HtmlCellRenderer.cs | 6 +- .../Extensions/Renderers/MarkdownRenderer.cs | 6 +- .../Renderers/MermaidCellRenderer.cs | 6 +- .../Renderers/ParametersCellRenderer.cs | 82 +- src/Verso/Extensions/Themes/VersoDarkTheme.cs | 7 +- .../Themes/VersoHighContrastTheme.cs | 7 +- .../Extensions/Themes/VersoLightTheme.cs | 7 +- .../ToolbarActions/ClearCellOutputAction.cs | 7 +- .../ToolbarActions/ClearOutputsAction.cs | 7 +- .../ToolbarActions/ExportHtmlAction.cs | 6 +- .../ToolbarActions/ExportMarkdownAction.cs | 6 +- .../ToolbarActions/ExportVersoAction.cs | 6 +- .../ToolbarActions/RestartKernelAction.cs | 10 +- .../Extensions/ToolbarActions/RunAllAction.cs | 7 +- .../ToolbarActions/RunCellAction.cs | 7 +- .../ToolbarActions/SwitchLayoutAction.cs | 7 +- .../ToolbarActions/SwitchThemeAction.cs | 7 +- src/Verso/Localization/VersoCultures.cs | 167 +++ src/Verso/Resources/Strings.qps-Ploc.resx | 376 +++++ src/Verso/Resources/Strings.resx | 481 +++++++ src/Verso/Verso.csproj | 23 + tests/Directory.Build.props | 13 + tests/EnglishTestCulture.cs | 37 + .../DiffSourcesTests.cs | 108 ++ .../ExtensionPanelTests.cs | 14 +- .../HostPanelsCultureTests.cs | 61 + .../NotebookPageCompareTests.cs | 6 +- .../PanelDisplayNamesTests.cs | 30 +- .../ServerNotebookServiceDiffTests.cs | 5 +- .../BuiltInExtensionCultureTests.cs | 108 ++ .../Localization/VersoCulturesTests.cs | 165 +++ vscode/.gitignore | 1 + vscode/l10n/bundle.l10n.json | 301 ++++ vscode/l10n/bundle.l10n.qps-ploc.json | 96 ++ vscode/package.json | 102 +- vscode/package.nls.json | 116 ++ vscode/package.nls.qps-ploc.json | 44 + vscode/src/blazor/blazorBridge.ts | 188 ++- vscode/src/blazor/blazorEditorProvider.ts | 74 +- vscode/src/copilot/participant.ts | 177 ++- vscode/src/copilot/tools.ts | 110 +- vscode/src/extension.ts | 24 +- vscode/src/git/gitBaselineProvider.ts | 65 +- vscode/src/host/dotnetRuntime.ts | 25 +- vscode/src/host/hostProcess.ts | 26 +- vscode/src/localization.ts | 77 ++ vscode/test/suite/blazorBridge.diff.test.ts | 28 +- 116 files changed, 7019 insertions(+), 704 deletions(-) create mode 100644 build/i18n/README.md create mode 100644 build/i18n/check.py create mode 100644 build/i18n/glossary.md create mode 100644 build/i18n/pseudo.py create mode 100644 build/i18n/resources.py create mode 100644 build/i18n/translate.py create mode 100644 src/Verso.Blazor.Shared/Models/DiffSources.cs create mode 100644 src/Verso.Blazor.Shared/Resources/Plural.cs create mode 100644 src/Verso.Blazor.Shared/Resources/UI.de.resx create mode 100644 src/Verso.Blazor.Shared/Resources/UI.qps-Ploc.resx create mode 100644 src/Verso.Blazor.Shared/Resources/UI.resx create mode 100644 src/Verso.Blazor/Localization/VersoLocalization.cs create mode 100644 src/Verso.Cli/Utilities/LanguageOption.cs create mode 100644 src/Verso/Localization/VersoCultures.cs create mode 100644 src/Verso/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso/Resources/Strings.resx create mode 100644 tests/Directory.Build.props create mode 100644 tests/EnglishTestCulture.cs create mode 100644 tests/Verso.Blazor.Shared.Tests/DiffSourcesTests.cs create mode 100644 tests/Verso.Blazor.Shared.Tests/HostPanelsCultureTests.cs create mode 100644 tests/Verso.Tests/Localization/BuiltInExtensionCultureTests.cs create mode 100644 tests/Verso.Tests/Localization/VersoCulturesTests.cs create mode 100644 vscode/l10n/bundle.l10n.json create mode 100644 vscode/l10n/bundle.l10n.qps-ploc.json create mode 100644 vscode/package.nls.json create mode 100644 vscode/package.nls.qps-ploc.json create mode 100644 vscode/src/localization.ts diff --git a/build/i18n/README.md b/build/i18n/README.md new file mode 100644 index 00000000..fc714b08 --- /dev/null +++ b/build/i18n/README.md @@ -0,0 +1,112 @@ +# Translations + +Verso's interface is written in English and translated into German, Spanish, Japanese, and +Simplified Chinese. The translations are committed files, so building Verso and checking +the translations need no API key and no network. Only regenerating them does. + +Strings live in two places. .NET code reads `.resx` files under `src//Resources`, +one set per assembly, which the build turns into a satellite assembly per language. The +editor extension reads `vscode/package.nls.json` for anything named in its manifest and +`vscode/l10n/bundle.l10n.json` for the strings its own code shows. + +## Which file a string belongs in + +Two languages are in play, and a reader may set them differently. The editor writes its own +menus, commands, and settings in whatever display language its workbench is set to, and no +Verso setting can override that. The notebook interface, the host, and the CLI answer in the +language `verso.language` or `--language` asks for. So the same words can be needed in both +places, and that is not duplication to remove: the Compare command in the palette and the +Compare panel inside a notebook can legitimately be showing two different languages at once. + +The rule that decides it: if the string is drawn by the editor, it belongs in one of the two +JSON bundles; if it is drawn inside a notebook, it belongs in a `.resx`. Where the editor +sends something a notebook will draw, it sends an identifier and the notebook chooses the +words, which is what `DiffSources` does for the comparison baselines. + +Two kinds of string stay in English wherever they appear, and both are marked with a comment +in the code saying so: + +- **Anything read by a model rather than a person.** The chat participant's system prompt, + the tool descriptions in the manifest, and everything the tools hand back. Translating + them changes how well tools are chosen without changing anything a reader sees. +- **Text that only a fault produces.** Log lines and guards against programmer error stay + searchable, so a stack trace and an issue report still match. + +## Adding a string + +Add it to the neutral file, in English, with a note saying where it appears and what +constrains it. In a `.resx` that is the `` element. In `package.nls.json` it is the +`{ "message": ..., "comment": [...] }` form. In extension code it is the object form of +`vscode.l10n.t`, and the note travels into the bundle when it is exported: + +```ts +vscode.l10n.t({ message: "tag", comment: ["A name pinned to one point in a project's history."] }) +``` + +That note is the only context a translator gets, and "keep short, it sits next to an icon" +is the difference between a button that fits and one that does not. It matters most for +single words: `Type`, `Value`, and `No` all mean more than one thing on their own. + +After editing extension code, re-export the bundle so the new strings reach a translator: + +``` +cd vscode && npx @vscode/l10n-dev export --outDir ./l10n ./src +``` + +Then, from the repository root: + +``` +python3 build/i18n/translate.py # fills in the four languages +python3 build/i18n/pseudo.py # regenerates the pseudo-locale +python3 build/i18n/check.py # confirms the four agree with the English +``` + +`translate.py` only asks for keys a language does not already have, so this is cheap for a +handful of strings. It needs `pip install anthropic` and `ANTHROPIC_API_KEY`. + +A machine translation is a draft. Have somebody who reads the language look over anything +user-facing before it ships. + +## Counting things + +Neither format has plural rules, so a count is written as two entries and the code picks +between them: + +```ts +count === 1 ? vscode.l10n.t("{0} cell", count) : vscode.l10n.t("{0} cells", count) +``` + +Two forms cover German, Spanish, Japanese, and Simplified Chinese; the last two have one +form and translate both entries the same way. A language with more forms than two, such as +Russian or Polish, would need a real plural selector, and that is worth knowing before +adding one. What must not happen is `cell(s)`, which no other language can copy. + +## Adding a language + +1. Add the tag to `VersoCultures.Supported` in `src/Verso/Localization/VersoCultures.cs`. +2. Add it to `LOCALES` and `LANGUAGE_NAMES` in `resources.py`, and to `VSCODE_IDS` if the + editor spells it differently, as it does for Chinese. +3. Add it to the `verso.language` setting's `enum` and `enumItemLabels` in + `vscode/package.json`, and to `SHIPPED` in `vscode/src/localization.ts`. +4. Run `translate.py`, then `check.py`. + +## Checking the work + +`check.py` is the one to run in continuous integration. It reports a key the English has +and a language does not, a key a language still has after English dropped it, a translation +that lost a `{0}` it was meant to fill in, and an empty translation. It also checks the +editor manifest against `package.nls.json` in both directions, because a `%key%` with +nothing behind it is drawn on screen exactly as written. All of those otherwise stay quiet +until the string is finally shown to somebody. + +The pseudo-locale is the coverage check. Run the interface in `qps-Ploc` and every +translated string appears accented, bracketed, and padded, so anything still in plain +English is a string nobody moved into a resource file, and anything clipped is a place a +real translation will not fit. + +``` +verso serve --language qps-Ploc +``` + +In the editor, set `verso.language` to `qps-Ploc` by hand. It is deliberately absent from +the setting's dropdown, because it is a development aid rather than a language. diff --git a/build/i18n/check.py b/build/i18n/check.py new file mode 100644 index 00000000..46bea6de --- /dev/null +++ b/build/i18n/check.py @@ -0,0 +1,133 @@ +"""Checks the translations against the English strings they came from. + +Translations are committed files, so they drift: a string gets added and only English +knows about it, a key gets renamed and four files keep the old one, a translation quietly +loses the `{0}` that was going to be filled in with a file name. None of that shows up at +build time, because a missing translation falls back to English and a broken placeholder +only fails when the message is finally shown. + + python3 build/i18n/check.py + +Prints what is wrong and exits non-zero, so it can run in continuous integration. It needs +no API key: it reads the committed files and nothing else. +""" + +from __future__ import annotations + +import json +import re +import sys + +from resources import LOCALES, PSEUDO, REPO_ROOT, discover, display, placeholders + +# How the editor's manifest points at a string it wants translated. A reference with no +# entry behind it is drawn on screen exactly as written, braces and all. +NLS_REFERENCE = re.compile(r"^%([^%]+)%$") + + +def check_locale(resource_set, locale: str, source) -> list[str]: + """Every problem found in one language of one resource set.""" + problems: list[str] = [] + path = resource_set.path_for(locale) + + if not path.exists(): + return [f"{display(path)}: missing, {len(source)} strings untranslated"] + + translated = resource_set.translation(locale) + where = display(path) + + for key in sorted(set(source) - set(translated)): + problems.append(f"{where}: missing key {key}") + + # An orphan is usually a rename that only landed in English, and it is worth reporting + # rather than deleting, because the translation it holds may still be wanted under the + # new name. + for key in sorted(set(translated) - set(source)): + problems.append(f"{where}: no longer in English, key {key}") + + for key in sorted(set(source) & set(translated)): + expected = sorted(placeholders(source[key].value)) + actual = sorted(placeholders(translated[key].value)) + if expected != actual: + problems.append( + f"{where}: placeholders differ in {key}, " + f"English has {expected or 'none'} and the translation has {actual or 'none'}" + ) + + if not translated[key].value.strip(): + problems.append(f"{where}: empty translation for {key}") + + return problems + + +def check_manifest() -> list[str]: + """Whether the editor manifest and the strings behind it still agree. + + The manifest names its translatable strings indirectly, and nothing verifies the + naming: a reference with no entry is drawn literally, so a mistyped key shows up as + `%command.newNotebook.title%` in the Command Palette rather than as a failure. An entry + with nothing referencing it is the same mistake seen from the other side, and it also + costs every translator the work of translating a string nobody will read. + """ + manifest = REPO_ROOT / "vscode" / "package.json" + strings = REPO_ROOT / "vscode" / "package.nls.json" + if not manifest.exists() or not strings.exists(): + return [] + + referenced: set[str] = set() + + def walk(node) -> None: + if isinstance(node, dict): + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + elif isinstance(node, str) and (match := NLS_REFERENCE.match(node)): + referenced.add(match.group(1)) + + walk(json.loads(manifest.read_text(encoding="utf-8"))) + declared = set(json.loads(strings.read_text(encoding="utf-8"))) + + where = display(strings) + return [ + *(f"{where}: {key} is used in package.json but not declared here" + for key in sorted(referenced - declared)), + *(f"{where}: {key} is declared here but nothing in package.json uses it" + for key in sorted(declared - referenced)), + ] + + +def main() -> int: + sets = discover() + if not sets: + print("No neutral resource files found.", file=sys.stderr) + return 1 + + problems: list[str] = check_manifest() + strings = 0 + + for resource_set in sets: + source = resource_set.source() + strings += len(source) + + # The pseudo-locale is checked alongside the real ones. It is generated, so a + # problem there means pseudo.py has not been run since the English changed, which + # is worth catching for the same reason: it is what the coverage sweep runs on. + for locale in [*LOCALES, PSEUDO]: + problems.extend(check_locale(resource_set, locale, source)) + + for problem in problems: + print(problem) + + languages = len(LOCALES) + 1 + if problems: + print(f"\n{len(problems)} problems across {strings} strings in {languages} languages.") + return 1 + + print(f"{strings} strings, {languages} languages, no problems.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/i18n/glossary.md b/build/i18n/glossary.md new file mode 100644 index 00000000..43e872fb --- /dev/null +++ b/build/i18n/glossary.md @@ -0,0 +1,67 @@ +# Translation glossary + +Rules for translating Verso's interface. `translate.py` passes this file to the model with +every batch, and a human reviewer should hold a translation to the same rules. + +## Never translate + +These are names, not words. They appear as written in every language. + +- **Verso**, **Verso Notebooks**, **Datafication** +- Kernel and language names: **C#**, **F#**, **Python**, **PowerShell**, **JavaScript**, + **SQL**, **HTTP**, **Markdown**, **Mermaid**, **Blazor**, **Monaco**, **NuGet**, + **Jupyter**, **Git** +- File extensions, exactly as spelled: `.verso`, `.ipynb`, `.dib`, `.md`, `.csx`, `.fsx` +- Magic commands and anything else typed at a keyboard: `#!import`, `#!pip`, `#!restart`, + `#!extension`, `#!connect` +- Identifiers of any kind: extension ids, setting names such as `verso.python.useUv`, + environment variables such as `VERSO_LANGUAGE`, command-line options such as + `--language`, MIME types, HTTP method names +- Anything typed to make something happen: `@verso` addresses the chat assistant, `/props` + is a chat command, `HEAD` and `main` are version control names +- The names languages call themselves. A language picker lists **English**, **Deutsch**, + **Español**, **日本語**, **简体中文**, and those read the same whichever language the + picker is in. Only the entry meaning "take it from the editor" is a word to translate. + +## Placeholders + +A placeholder is filled in at runtime. Reproduce every one exactly, spelling and case +included. + +- `{0}`, `{1}` are positional. Move them wherever the sentence needs them, but keep all of + them and add none. +- `{name}` is named. Same rule, and never translate the name inside the braces. +- `{{` and `}}` are an escaped literal brace. Leave them doubled. + +## House terms + +Translate these consistently. Where a target language has an established computing term, +prefer it over a coinage; where the English word is what practitioners actually say in that +language, keep the English word. + +| English | Meaning in Verso | +|---|---| +| notebook | The document: cells, outputs, and metadata in one file | +| cell | One unit of the notebook, holding code or prose | +| output | What a cell produced when it ran | +| kernel | The process that runs a cell's code | +| run / execute | Both mean starting a cell. Use one word consistently | +| extension | An add-on that contributes a kernel, formatter, or panel | +| panel | A dockable region of the interface | +| layout | The arrangement a notebook is rendered with | +| parameter | A named value a notebook declares and a caller supplies | +| variable | A value produced by running a cell | +| trust | The user's decision to let something run | + +## Tone and shape + +- Address the reader the way the target language's own software does. German uses **Sie**; + Japanese uses **です・ます**. +- Match the source's register: buttons and menu entries are short and imperative, messages + are complete sentences with a full stop. +- Keep it about as long as the English. A button label that doubles in length is clipped. +- Reproduce the source's punctuation and capitalisation conventions for the target + language, not for English. German capitalises nouns; Japanese and Chinese use their own + full stop and need no space before it. +- Never add quotation marks, brackets, or a trailing full stop the English does not have. +- Translate nothing that is already a code sample or a literal value. diff --git a/build/i18n/pseudo.py b/build/i18n/pseudo.py new file mode 100644 index 00000000..a2899019 --- /dev/null +++ b/build/i18n/pseudo.py @@ -0,0 +1,74 @@ +"""Generates the pseudo-locale from the English strings. + +The pseudo-locale is a translation nobody reads. Every letter is accented and every string +is bracketed and padded, which makes it worth running for two reasons: anything still +showing plain English is a string somebody forgot to move into a resource file, and +anything clipped or wrapped badly is a place where a real translation, which tends to run +longer than English, will not fit. + + python3 build/i18n/pseudo.py + +Rerun it whenever English strings are added or changed. The output is committed, so a +reviewer sees the same thing the next person to run the interface will. +""" + +from __future__ import annotations + +import sys + +from resources import PLACEHOLDER, PSEUDO, Entry, discover, display + +# One accented stand-in per letter, chosen to stay recognisable so a screenshot is still +# readable enough to tell which string is which. +ACCENTS = str.maketrans( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + "àbçdéfghïjklmñòpqrštùvwxÿzÀBÇDÉFGHÏJKLMÑÒPQRŠTÙVWXÝZ", +) + +# Padding, so a string that only just fits in English is seen not to fit here. Real +# translations into German run roughly a third longer than the English they come from. +PAD = "···" + + +def disguise(text: str) -> str: + """Accents a string, leaving its placeholders alone. + + A placeholder is filled in at runtime with a name or a number, so accenting one would + either break the lookup or produce a value the reader cannot recognise. + """ + parts: list[str] = [] + position = 0 + + for match in PLACEHOLDER.finditer(text): + parts.append(text[position : match.start()].translate(ACCENTS)) + parts.append(match.group(0)) + position = match.end() + + parts.append(text[position:].translate(ACCENTS)) + return "".join(parts) + + +def main() -> int: + sets = discover() + if not sets: + print("No neutral resource files found.", file=sys.stderr) + return 1 + + for resource_set in sets: + source = resource_set.source() + generated = { + key: Entry(f"[!!{disguise(entry.value)}{PAD}!!]") + for key, entry in source.items() + } + + path = resource_set.path_for(PSEUDO) + path.parent.mkdir(parents=True, exist_ok=True) + resource_set.save(path, generated) + + print(f"{len(generated):5d} {display(path)}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/i18n/resources.py b/build/i18n/resources.py new file mode 100644 index 00000000..9790bb5a --- /dev/null +++ b/build/i18n/resources.py @@ -0,0 +1,260 @@ +"""Reading and writing the files that hold Verso's translatable strings. + +Two formats carry the interface: .NET resource files under `src/**/Resources`, and the +JSON bundles the editor extension uses. They differ enough in shape that the scripts +around them would each grow two code paths, so both are wrapped here and everything else +in this directory works in terms of keys, values, and translator notes. + +Run nothing here directly. `pseudo.py`, `translate.py`, and `check.py` are the entry +points. +""" + +from __future__ import annotations + +import json +import re +import xml.etree.ElementTree as ElementTree +from dataclasses import dataclass +from pathlib import Path +from xml.sax.saxutils import escape + +# Where the scripts sit relative to the repository. +REPO_ROOT = Path(__file__).resolve().parents[2] + +# The languages Verso ships an interface in, English aside. Mirrors VersoCultures.Supported. +LOCALES = ["de", "es", "ja", "zh-Hans"] + +# Generated rather than translated, and never offered in a picker. See pseudo.py. +PSEUDO = "qps-Ploc" + +# Human names, used to address the translator and to caption a report. +LANGUAGE_NAMES = { + "de": "German", + "es": "Spanish", + "ja": "Japanese", + "zh-Hans": "Simplified Chinese", + PSEUDO: "Pseudo-locale", +} + +# The editor names languages its own way, so its file names do not match the .NET ones. +# Only the entries that actually differ are listed; anything absent is used as written. +VSCODE_IDS = { + "zh-Hans": "zh-cn", + PSEUDO: "qps-ploc", +} + +# Anything a translation has to carry through unchanged: .NET's positional {0} and the +# named {placeholder} the editor bundles use. Doubled braces are an escaped literal brace +# and are matched first so they are not mistaken for an empty placeholder. +PLACEHOLDER = re.compile(r"\{\{|\}\}|\{[^{}]*\}") + +# Copied from a resource file written by the .NET tooling, so generated files are +# byte-identical in everything but their entries and no editor reformats them on open. +RESX_HEADER = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + +""" + + +@dataclass +class Entry: + """One string, and whatever context a translator was given for it.""" + + value: str + comment: str = "" + + +def display(path: Path) -> str: + """A path as it should appear in a report, relative to the repository where it can be.""" + try: + return str(path.relative_to(REPO_ROOT)) + except ValueError: + return str(path) + + +def placeholders(text: str) -> list[str]: + """The placeholder tokens in a string, in the order they appear. + + Which tokens are present is what a check compares, not the order they arrive in: a + translation is free to move `{0}` behind `{1}` because the target language puts the + sentence together differently, but a translation that loses `{0}` altogether produces + a message with a hole in it rather than an error. + """ + return [m.group(0) for m in PLACEHOLDER.finditer(text) if m.group(0) not in ("{{", "}}")] + + +class ResourceSet: + """A neutral file and the translated files that shadow it.""" + + def __init__(self, neutral: Path): + self.neutral = neutral + + def path_for(self, locale: str) -> Path: + raise NotImplementedError + + def load(self, path: Path) -> dict[str, Entry]: + raise NotImplementedError + + def save(self, path: Path, entries: dict[str, Entry]) -> None: + raise NotImplementedError + + def source(self) -> dict[str, Entry]: + return self.load(self.neutral) + + def translation(self, locale: str) -> dict[str, Entry]: + """The entries already translated into a language, empty when the file is new.""" + path = self.path_for(locale) + return self.load(path) if path.exists() else {} + + +class ResxSet(ResourceSet): + """A .NET resource file, which compiles into one satellite assembly per language.""" + + def path_for(self, locale: str) -> Path: + return self.neutral.with_name(f"{self.neutral.stem}.{locale}.resx") + + def load(self, path: Path) -> dict[str, Entry]: + root = ElementTree.parse(path).getroot() + entries: dict[str, Entry] = {} + + for data in root.findall("data"): + name = data.get("name") + # Entries carrying a type or mimetype hold something other than a string, + # such as an icon. There are none today, and translating one would be wrong. + if name is None or data.get("type") or data.get("mimetype"): + continue + + value = data.findtext("value") or "" + comment = data.findtext("comment") or "" + entries[name] = Entry(value, comment) + + return entries + + def save(self, path: Path, entries: dict[str, Entry]) -> None: + lines = [RESX_HEADER] + + for name in sorted(entries): + entry = entries[name] + lines.append(f' \n') + lines.append(f" {escape(entry.value)}\n") + if entry.comment: + lines.append(f" {escape(entry.comment)}\n") + lines.append(" \n") + + lines.append("") + path.write_text("".join(lines), encoding="utf-8") + + +class JsonSet(ResourceSet): + """One of the editor extension's bundles. + + Covers both `package.nls.json`, which names commands and settings, and + `bundle.l10n.json`, which holds the strings the extension code passes through + `vscode.l10n.t`. They share a format: a key maps either to the string itself or to an + object carrying the string plus notes for whoever translates it. + """ + + def path_for(self, locale: str) -> Path: + stem = self.neutral.name[: -len(".json")] + return self.neutral.with_name(f"{stem}.{VSCODE_IDS.get(locale, locale)}.json") + + def load(self, path: Path) -> dict[str, Entry]: + raw = json.loads(path.read_text(encoding="utf-8")) + entries: dict[str, Entry] = {} + + for name, value in raw.items(): + if isinstance(value, dict): + comment = value.get("comment", "") + if isinstance(comment, list): + comment = " ".join(comment) + entries[name] = Entry(value.get("message", ""), comment) + else: + entries[name] = Entry(value) + + return entries + + def save(self, path: Path, entries: dict[str, Entry]) -> None: + # Notes are for translators, so they stay in the neutral file and are not copied + # into the translations, where they would only be read back by the next script. + body = {name: entries[name].value for name in sorted(entries)} + path.write_text( + json.dumps(body, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def discover() -> list[ResourceSet]: + """Every neutral file in the repository, in a stable order. + + A file is neutral when its name carries no language, so `UI.resx` is a source and + `UI.de.resx` is one of its translations. + """ + sets: list[ResourceSet] = [] + + for path in sorted((REPO_ROOT / "src").glob("**/Resources/*.resx")): + if "." in path.stem or {"bin", "obj"} & set(path.parts): + continue + sets.append(ResxSet(path)) + + for relative in ("vscode/package.nls.json", "vscode/l10n/bundle.l10n.json"): + path = REPO_ROOT / relative + if path.exists(): + sets.append(JsonSet(path)) + + return sets diff --git a/build/i18n/translate.py b/build/i18n/translate.py new file mode 100644 index 00000000..aedc66ce --- /dev/null +++ b/build/i18n/translate.py @@ -0,0 +1,220 @@ +"""Fills in the translations that are missing from the shipped languages. + +Reads every neutral resource file, works out which keys a language has not been given yet, +and asks Claude for those and only those. Existing translations are left alone, so adding a +handful of English strings costs a handful of translations rather than a retranslation of +the interface. + + pip install anthropic + export ANTHROPIC_API_KEY=... + python3 build/i18n/translate.py # everything missing, all languages + python3 build/i18n/translate.py --locale ja # one language + python3 build/i18n/translate.py --all # replace what is already there + python3 build/i18n/translate.py --dry-run # report the work without doing it + +The output is committed, and nothing in the build or in continuous integration runs this, +so no API key is needed to build Verso or to check the translations. Run `check.py` +afterwards, and have a reader of the language look over the result before shipping it. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +from resources import ( + LANGUAGE_NAMES, + LOCALES, + REPO_ROOT, + Entry, + discover, + display, + placeholders, +) + +MODEL = "claude-opus-5" + +# Enough for a batch of short interface strings with room to spare. A batch that would +# exceed it fails loudly rather than returning a truncated object, because the response has +# to parse as JSON to be used at all. +MAX_TOKENS = 16000 + +# Small enough that one bad batch is cheap to redo, large enough that the model sees +# neighbouring strings and keeps their wording consistent with each other. +BATCH_SIZE = 40 + +SYSTEM = """You are translating the interface of Verso, a computational notebook \ +application, from English into {language}. The people reading it are programmers, data \ +analysts, and scientists, and they will read your translation while working. + +Follow this glossary exactly. + +{glossary} + +You are given a JSON array. Each element has a `key` naming the string, a `source` holding \ +the English, and sometimes a `note` written for you by the developer explaining where the \ +string appears and what constrains it. Honour the note. + +Reply with a JSON object mapping each key to its translation, and nothing else: no prose \ +before or after it, no code fence, no commentary. Include every key you were given.""" + + +def build_prompt(items: list[tuple[str, Entry]]) -> str: + payload = [] + for key, entry in items: + element = {"key": key, "source": entry.value} + if entry.comment: + element["note"] = entry.comment + payload.append(element) + + return json.dumps(payload, ensure_ascii=False, indent=2) + + +def parse_reply(text: str) -> dict[str, str]: + """Reads the model's reply, tolerating a code fence it was asked not to add.""" + body = text.strip() + + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + if body.rstrip().endswith("```"): + body = body.rstrip()[: -len("```")] + + return json.loads(body) + + +def translate_batch(client, locale: str, glossary: str, items: list[tuple[str, Entry]]) -> dict[str, str]: + """One request. Returns the translations that came back, whether or not they are sound.""" + system = SYSTEM.format(language=LANGUAGE_NAMES[locale], glossary=glossary) + + # Streamed because a large batch can run long enough to reach the request timeout, and + # a timeout here would throw away a batch that was nearly finished. + with client.messages.stream( + model=MODEL, + max_tokens=MAX_TOKENS, + thinking={"type": "adaptive"}, + system=system, + messages=[{"role": "user", "content": build_prompt(items)}], + ) as stream: + message = stream.get_final_message() + + text = "".join(block.text for block in message.content if block.type == "text") + return parse_reply(text) + + +def sound(source: str, translation: str) -> bool: + """Whether a translation can be used without a person looking at it first.""" + return bool(translation.strip()) and sorted(placeholders(source)) == sorted( + placeholders(translation) + ) + + +def translate(client, locale: str, glossary: str, items: list[tuple[str, Entry]]) -> dict[str, str]: + """Translates a batch and retries once for anything that came back unusable.""" + result = translate_batch(client, locale, glossary, items) + + retry = [ + (key, entry) + for key, entry in items + if not sound(entry.value, result.get(key, "")) + ] + + if retry: + print(f" retrying {len(retry)} strings", flush=True) + result.update(translate_batch(client, locale, glossary, retry)) + + for key, entry in items: + if not sound(entry.value, result.get(key, "")): + # Left to English rather than written out wrong. check.py reports it as + # missing, which is the truth and is fixable by rerunning. + print(f" gave up on {key}", file=sys.stderr) + result.pop(key, None) + + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--locale", + action="append", + choices=LOCALES, + help="Translate one language. Repeatable. Defaults to all of them.", + ) + parser.add_argument( + "--all", + action="store_true", + help="Retranslate strings that already have a translation.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report what would be translated without calling the API.", + ) + args = parser.parse_args() + + locales = args.locale or LOCALES + glossary = (REPO_ROOT / "build" / "i18n" / "glossary.md").read_text(encoding="utf-8") + + sets = discover() + if not sets: + print("No neutral resource files found.", file=sys.stderr) + return 1 + + client = None + if not args.dry_run: + try: + import anthropic + except ImportError: + print("This needs the anthropic package: pip install anthropic", file=sys.stderr) + return 1 + + if not os.environ.get("ANTHROPIC_API_KEY"): + print("Set ANTHROPIC_API_KEY.", file=sys.stderr) + return 1 + + client = anthropic.Anthropic() + + for resource_set in sets: + source = resource_set.source() + + for locale in locales: + existing = resource_set.translation(locale) + wanted = [ + (key, source[key]) + for key in sorted(source) + if args.all or key not in existing + ] + + if not wanted: + continue + + path = resource_set.path_for(locale) + print(f"{display(path)}: {len(wanted)} strings", flush=True) + + if args.dry_run: + continue + + translations: dict[str, str] = {} + for start in range(0, len(wanted), BATCH_SIZE): + batch = wanted[start : start + BATCH_SIZE] + print(f" {start + 1}-{start + len(batch)}", flush=True) + translations.update(translate(client, locale, glossary, batch)) + + # Rebuilt from the English keys, so a key that was renamed or dropped leaves + # with it rather than lingering in four languages. + merged = { + key: Entry(translations.get(key) or existing[key].value) + for key in source + if key in translations or key in existing + } + + path.parent.mkdir(parents=True, exist_ok=True) + resource_set.save(path, merged) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/Verso.Blazor.Shared/Components/CustomLayoutFrame.razor b/src/Verso.Blazor.Shared/Components/CustomLayoutFrame.razor index a03fc835..599499a9 100644 --- a/src/Verso.Blazor.Shared/Components/CustomLayoutFrame.razor +++ b/src/Verso.Blazor.Shared/Components/CustomLayoutFrame.razor @@ -4,6 +4,7 @@ @using Microsoft.JSInterop @using Verso.Abstractions @using Verso.Blazor.Shared.Models +@using Verso.Blazor.Shared.Resources @using Verso.Blazor.Shared.Services @implements IAsyncDisposable @inject IJSRuntime JS @@ -12,7 +13,7 @@ {
@_errorMessage - +
} else if (_srcdoc is not null) @@ -66,7 +67,7 @@ else if (_srcdoc is not null) { if (Service.ActiveLayout is not { } active || string.IsNullOrEmpty(active.LayoutId)) { - _errorMessage = "Layout failed to load: no active isolated layout."; + _errorMessage = UI.Layout_ErrorNoActive; return; } @@ -79,7 +80,7 @@ else if (_srcdoc is not null) } catch (Exception ex) { - _errorMessage = $"Layout failed to load: could not allocate frame instance ({ex.Message})."; + _errorMessage = string.Format(UI.Layout_ErrorAllocateFrame, ex.Message); return; } @@ -90,21 +91,21 @@ else if (_srcdoc is not null) } catch (Exception ex) { - _errorMessage = $"Layout failed to load: renderer package fetch failed ({ex.Message})."; + _errorMessage = string.Format(UI.Layout_ErrorPackageFetch, ex.Message); return; } if (package is null) { - _errorMessage = "Layout failed to load: renderer package is empty."; + _errorMessage = UI.Layout_ErrorPackageEmpty; return; } _rendererProtocolVersion = package.RendererProtocolVersion; - if (!TryComposeContentSecurityPolicy(package.ContentSecurityPolicy, out var composedCsp, out var cspError)) + if (!TryComposeContentSecurityPolicy(package.ContentSecurityPolicy, out var composedCsp, out var rejectedSource)) { - _errorMessage = $"Layout failed to load: {cspError}"; + _errorMessage = string.Format(UI.Layout_ErrorDisallowedSource, rejectedSource); return; } @@ -115,7 +116,7 @@ else if (_srcdoc is not null) } catch (Exception ex) { - _errorMessage = $"Layout failed to load: bridge script unavailable ({ex.Message})."; + _errorMessage = string.Format(UI.Layout_ErrorBridgeScript, ex.Message); return; } @@ -200,7 +201,7 @@ else if (_srcdoc is not null) await InvokeAsync(() => { if (_disposed || _readyReceived) return; - _errorMessage = $"Layout failed to load: ready signal not received within {ReadyTimeoutMs / 1000}s."; + _errorMessage = string.Format(UI.Layout_ErrorNotReady, ReadyTimeoutMs / 1000); _srcdoc = null; StateHasChanged(); }); @@ -515,10 +516,12 @@ else if (_srcdoc is not null) return script; } + // Reports the offending source rather than a finished sentence, so the message the reader + // sees is composed once, in one resource, by the caller. private static bool TryComposeContentSecurityPolicy( - string? packageCsp, out string composed, out string error) + string? packageCsp, out string composed, out string rejectedSource) { - error = string.Empty; + rejectedSource = string.Empty; // The renderer package's files are exposed to the frame as blob: URLs and the // entry module is loaded with a dynamic import(blobUrl), so script-src must allow // blob: in addition to the inline bootstrap. 'unsafe-inline' alone blocks the @@ -549,7 +552,7 @@ else if (_srcdoc is not null) if (token != "'none'" && token != "blob:" && token != "'self'") { composed = string.Empty; - error = $"CSP rejected: package declares a disallowed connect-src source ('{token}')."; + rejectedSource = token; return false; } } diff --git a/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor b/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor index 58b8dfe4..a5a229ef 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/Cell.razor @@ -15,7 +15,7 @@ { } @@ -23,9 +23,9 @@ { @@ -35,8 +35,8 @@ { } @@ -44,8 +44,8 @@ { } @@ -132,13 +132,13 @@ } @if (Index > 0) { - + } @if (!IsLast) { - + } - + @@ -281,11 +281,11 @@ private static string DiffMarkLabel(CellDiffKind kind) => kind switch { - CellDiffKind.Added => "Added", - CellDiffKind.Removed => "Removed", - CellDiffKind.Modified => "Modified", - CellDiffKind.Moved => "Moved", - _ => "Unchanged" + CellDiffKind.Added => UI.Diff_Added, + CellDiffKind.Removed => UI.Diff_Removed, + CellDiffKind.Modified => UI.Diff_Modified, + CellDiffKind.Moved => UI.Diff_Moved, + _ => UI.Diff_Unchanged }; private List _enabledCellActions = new(); @@ -312,9 +312,9 @@ private void RecoverOutputRender() => _outputErrorBoundary?.Recover(); private RenderFragment RenderOutputError => renderError => @
-
This output could not be rendered
+
@UI.Output_RenderFailed
@renderError.Message
- +
; // The type/language menus render in the browser top layer (popover) so they paint above the @@ -367,7 +367,13 @@ ? $"--verso-output-preview-lines: {OutputPreviewLineCount};" : ""; - private string OutputSummaryText => $"{CellData.Outputs.Count} output(s) hidden"; + private string OutputSummaryText => string.Format( + Plural.Of(CellData.Outputs.Count, UI.Cell_OutputsHidden_One, UI.Cell_OutputsHidden_Other), + CellData.Outputs.Count); + + private string SectionToggleLabel => IsSectionCollapsed ? UI.Cell_ExpandSection : UI.Cell_CollapseSection; + + private string InputToggleLabel => IsInputCollapsed ? UI.Cell_ExpandCode : UI.Cell_CollapseCode; private string CollapsedSourcePreview => BuildCollapsedSourcePreview(CellData.Source, InputPreviewLineCount); diff --git a/src/Verso.Blazor.Shared/Components/Notebook/CellPropertiesPanel.razor b/src/Verso.Blazor.Shared/Components/Notebook/CellPropertiesPanel.razor index 468139a3..75f33dda 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/CellPropertiesPanel.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/CellPropertiesPanel.razor @@ -4,25 +4,25 @@ @if (!Service.IsLoaded) {
-

No notebook is open.

+

@UI.Common_NoNotebookOpen

} else if (SelectedCellId is null) {
-

Select a cell to view its properties.

+

@UI.Properties_SelectACell

} else if (_loading) {
-

Loading...

+

@UI.Common_Loading

} else if (_sections.Count == 0) {
-

No properties available for this cell.

+

@UI.Properties_None

} else diff --git a/src/Verso.Blazor.Shared/Components/Notebook/ComparePanel.razor b/src/Verso.Blazor.Shared/Components/Notebook/ComparePanel.razor index 6abbe008..af1588bf 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/ComparePanel.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/ComparePanel.razor @@ -14,13 +14,13 @@ @if (ShowSourceList) {
-
Compare with
+
@UI.Compare_CompareWith
@if (Comparison.Sources is null) {
- Looking for baselines... + @UI.Compare_LookingForBaselines
} else @@ -52,20 +52,20 @@ @if (Comparison.Result is not null) {
- +
} else {
- Nothing is being compared yet. The notebook is untouched either way. + @UI.Compare_NothingYet
} } else if (Comparison.Result is { } diff) {
-
Baseline
+
@UI.Compare_Baseline
@diff.BaselineLabel @@ -74,23 +74,23 @@
@if (diff.Summary.Added > 0) { - @diff.Summary.Added added + @(string.Format(UI.Compare_SummaryAdded, diff.Summary.Added)) } @if (diff.Summary.Removed > 0) { - @diff.Summary.Removed removed + @(string.Format(UI.Compare_SummaryRemoved, diff.Summary.Removed)) } @if (diff.Summary.Modified > 0) { - @diff.Summary.Modified modified + @(string.Format(UI.Compare_SummaryModified, diff.Summary.Modified)) } @if (diff.Summary.Moved > 0) { - @diff.Summary.Moved moved + @(string.Format(UI.Compare_SummaryMoved, diff.Summary.Moved)) } @if (diff.Summary.Unchanged > 0) { - @diff.Summary.Unchanged unchanged + @(string.Format(UI.Compare_SummaryUnchanged, diff.Summary.Unchanged)) }
@@ -98,7 +98,7 @@ @if (Changes.Count > 0) {
-
Changes
+
@UI.Compare_Changes
@foreach (var change in Changes) { var c = change; @@ -128,7 +128,7 @@ {
- No cell changed against this baseline. + @UI.Compare_NoCellChanged
} @@ -136,14 +136,14 @@ @if (diff.MetadataChanges.Count > 0) {
-
Notebook settings
+
@UI.Compare_NotebookSettings
@foreach (var change in diff.MetadataChanges) {
@change.Field - @(change.BaselineValue ?? "(not set)") → @(change.CurrentValue ?? "(not set)") + @(change.BaselineValue ?? UI.Compare_NotSet) → @(change.CurrentValue ?? UI.Compare_NotSet)
@@ -153,9 +153,9 @@
- - + @onclick="() => OnOpenFullDiff.InvokeAsync()">@UI.Compare_OpenFullDiff + +
} @@ -251,18 +251,18 @@ private static string KindLabel(CellDiffKind kind) => kind switch { - CellDiffKind.Added => "Added", - CellDiffKind.Removed => "Removed", - CellDiffKind.Modified => "Modified", - CellDiffKind.Moved => "Moved", - _ => "Unchanged" + CellDiffKind.Added => UI.Diff_Added, + CellDiffKind.Removed => UI.Diff_Removed, + CellDiffKind.Modified => UI.Diff_Modified, + CellDiffKind.Moved => UI.Diff_Moved, + _ => UI.Diff_Unchanged }; private static string PositionLabel(CellDiffEntry entry) => entry.Kind switch { - CellDiffKind.Removed => $"was cell {entry.BaselineIndex + 1}", - CellDiffKind.Moved => $"cell {entry.BaselineIndex + 1} → {entry.CurrentIndex + 1}", - _ => $"cell {entry.CurrentIndex + 1}" + CellDiffKind.Removed => string.Format(UI.Compare_WasCell, entry.BaselineIndex + 1), + CellDiffKind.Moved => string.Format(UI.Compare_CellMoved, entry.BaselineIndex + 1, entry.CurrentIndex + 1), + _ => string.Format(UI.Compare_CellAt, entry.CurrentIndex + 1) }; // The first line with anything on it, which is what identifies a cell to someone @@ -274,7 +274,7 @@ { var source = (entry.CurrentCell ?? entry.BaselineCell)?.Source; if (string.IsNullOrWhiteSpace(source)) - return "(empty cell)"; + return UI.Compare_EmptyCell; var line = source .Split('\n') diff --git a/src/Verso.Blazor.Shared/Components/Notebook/DefaultCellList.razor b/src/Verso.Blazor.Shared/Components/Notebook/DefaultCellList.razor index 4d9707c2..41682603 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/DefaultCellList.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/DefaultCellList.razor @@ -83,7 +83,7 @@ { var typeId = ct.Id; + @onclick="() => Context.OnAddCell.InvokeAsync(typeId)">@(string.Format(UI.CellList_AddCellType, ct.DisplayName)) }
} diff --git a/src/Verso.Blazor.Shared/Components/Notebook/DiffErrorDialog.razor b/src/Verso.Blazor.Shared/Components/Notebook/DiffErrorDialog.razor index fac44639..d56f23bd 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/DiffErrorDialog.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/DiffErrorDialog.razor @@ -5,21 +5,21 @@ @if (Message is not null) {
-
diff --git a/src/Verso.Blazor.Shared/Components/Notebook/ExtensionConsentDialog.razor b/src/Verso.Blazor.Shared/Components/Notebook/ExtensionConsentDialog.razor index 9d213028..ff094917 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/ExtensionConsentDialog.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/ExtensionConsentDialog.razor @@ -6,7 +6,7 @@
@Title -
@@ -26,7 +26,7 @@ @if (Target is not null) { -

Into @Target

+

@(string.Format(UI.Consent_Target, Target))

}
@Warning @@ -34,7 +34,7 @@
@@ -55,19 +55,17 @@ private bool IsPackage => Kind == ConsentKind.Package; - private bool Plural => Extensions.Count != 1; - - private string Title => IsPackage ? "Package Install Required" : "Extension Consent Required"; + private string Title => IsPackage ? UI.Consent_TitlePackage : UI.Consent_TitleExtension; private string Introduction => IsPackage - ? $"This notebook needs the following Python package{(Plural ? "s" : "")} installed:" - : $"This notebook wants to load the following extension package{(Plural ? "s" : "")}:"; + ? Plural.Of(Extensions.Count, UI.Consent_IntroPackage_One, UI.Consent_IntroPackage_Other) + : Plural.Of(Extensions.Count, UI.Consent_IntroExtension_One, UI.Consent_IntroExtension_Other); private string Warning => IsPackage - ? "Packages run code on your machine. Only install packages you trust." - : "Extensions run code on your machine. Only approve packages you trust."; + ? UI.Consent_WarningPackage + : UI.Consent_WarningExtension; - private string ApproveLabel => IsPackage ? "Install" : "Approve All"; + private string ApproveLabel => IsPackage ? UI.Consent_ApproveInstall : UI.Consent_ApproveAll; /// Where the packages go, shown once for the request. private string? Target => Extensions diff --git a/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanel.razor b/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanel.razor index 60d5859a..c951c98a 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanel.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanel.razor @@ -6,15 +6,15 @@
@if (Service.LocalExtensionPickMode == LocalExtensionPickMode.Upload) {
@if (_isSearching) { - Searching... + @UI.Marketplace_Searching }
@@ -43,16 +43,16 @@ statement rather than a control. *@
- Installs into this notebook + data-verso-tip="@UI.Marketplace_InstallScope" + data-verso-tip-desc="@UI.Marketplace_InstallScopeDesc"> + @Emphasize(UI.Marketplace_InstallsInto, UI.Marketplace_ThisNotebook) @if (SourceLabel is { Length: > 0 }) { - from @SourceLabel + @Emphasize(UI.Marketplace_ResultsFrom, SourceLabel) }
@@ -67,7 +67,7 @@ @* There is no single-extension unload, so removal is only recorded now and takes effect on the next open. Saying so beats implying it is gone. *@
- Removed from this notebook. What it loaded stays active until the notebook is reopened. + @UI.Marketplace_RemovedNotice
} @@ -78,8 +78,8 @@ {
@(string.IsNullOrWhiteSpace(_searchQuery) - ? "No extensions installed. Search to add one." - : "No packages found.") + ? UI.Marketplace_NoneInstalled + : UI.Marketplace_NoResults)
} else @@ -124,8 +124,8 @@ { · } - local file + @UI.Marketplace_LocalFile }
} @@ -144,7 +144,7 @@ @foreach (var cap in r.Capabilities) { - @CapabilityDisplayNames.GetValueOrDefault(cap, cap) + @CapabilityLabel(cap) }
@@ -153,9 +153,9 @@ {
- Adds nothing + data-verso-tip="@UI.Marketplace_AddsNothing" + data-verso-tip-desc="@UI.Marketplace_AddsNothingDesc"> + @UI.Marketplace_AddsNothing
} @@ -163,22 +163,22 @@
@if (FormatDownloads(r.DownloadCount) is { } downloads) { - @downloads downloads + @string.Format(UI.Marketplace_Downloads, downloads) }
@if (r.UnavailableReason is not null) { - Not loaded + @UI.Marketplace_NotLoaded + @onclick="() => UninstallAsync(r.Id)">@UI.Marketplace_Remove } else if (r.IsInstalled) { - Installed + @UI.Marketplace_Installed + @onclick="() => UninstallAsync(r.Id)">@UI.Marketplace_Remove } else { @@ -188,7 +188,7 @@
+ @onclick="() => InstallAsync(r.Id, SelectedVersion(r.Id, r.Version))">@UI.Marketplace_Install }
@@ -240,7 +240,7 @@ @if (grouped.Count > 0) {
-
Loaded
+
@UI.Extensions_Loaded
@foreach (var group in grouped) { var category = group.Key; @@ -248,7 +248,7 @@
@if (isOpen) @@ -261,10 +261,10 @@
@e.Name
@e.Version
@if (e.Author is not null) @@ -280,7 +280,7 @@
@foreach (var cap in e.Capabilities) { - @CapabilityDisplayNames.GetValueOrDefault(cap, cap) + @CapabilityLabel(cap) }
} @@ -295,6 +295,7 @@
@using System.Globalization +@using System.Net @using Microsoft.AspNetCore.Components.Forms @implements IDisposable @@ -328,31 +329,6 @@ ""); - // Singular, because a chip names one contribution. Group headers name a set of them and - // pluralize on the way out, which keeps one map for both. - private static readonly Dictionary CapabilityDisplayNames = new() - { - ["LanguageKernel"] = "Language Kernel", - ["CellRenderer"] = "Cell Renderer", - ["DataFormatter"] = "Data Formatter", - ["CellType"] = "Cell Type", - ["NotebookSerializer"] = "Serializer", - ["Theme"] = "Theme", - ["LayoutEngine"] = "Layout Engine", - ["ToolbarAction"] = "Toolbar Action", - ["MagicCommand"] = "Magic Command", - ["NotebookPanel"] = "Panel", - // Capabilities with no entry here fall back to the raw name, which reads as - // one run-together word. Keep this map complete as capabilities are added. - ["NotebookPostProcessor"] = "Post-Processor", - ["CellPropertyProvider"] = "Cell Property Provider", - ["ExtensionSettings"] = "Settings Provider", - ["CellInteractionHandler"] = "Cell Interaction Handler", - ["LayoutInteractionHandler"] = "Layout Interaction Handler", - ["LayoutLifecycleHandler"] = "Layout Lifecycle Handler", - ["PanelInteractionHandler"] = "Panel Interaction Handler", - }; - private static readonly List CategoryOrder = new() { "LanguageKernel", "Theme", "LayoutEngine", "CellType", @@ -361,12 +337,75 @@ }; /// - /// Pluralizes a capability's display name for a group header. Everything takes an s except - /// a name that already ends in one, which takes es, so no header ever doubles an s. + /// What an extension adds, named for a chip on a package. A capability with no name here + /// falls back to its raw identifier, which reads as one run-together word, so keep this + /// complete as capabilities are added. + /// + /// + /// A switch rather than a lookup table, because a table built once would hold whichever + /// language happened to be current when the panel was first drawn. + /// + private static string CapabilityLabel(string capability) => capability switch + { + "LanguageKernel" => UI.Capability_LanguageKernel, + "CellRenderer" => UI.Capability_CellRenderer, + "DataFormatter" => UI.Capability_DataFormatter, + "CellType" => UI.Capability_CellType, + "NotebookSerializer" => UI.Capability_NotebookSerializer, + "Theme" => UI.Capability_Theme, + "LayoutEngine" => UI.Capability_LayoutEngine, + "ToolbarAction" => UI.Capability_ToolbarAction, + "MagicCommand" => UI.Capability_MagicCommand, + "NotebookPanel" => UI.Capability_NotebookPanel, + "NotebookPostProcessor" => UI.Capability_NotebookPostProcessor, + "CellPropertyProvider" => UI.Capability_CellPropertyProvider, + "ExtensionSettings" => UI.Capability_ExtensionSettings, + "CellInteractionHandler" => UI.Capability_CellInteractionHandler, + "LayoutInteractionHandler" => UI.Capability_LayoutInteractionHandler, + "LayoutLifecycleHandler" => UI.Capability_LayoutLifecycleHandler, + "PanelInteractionHandler" => UI.Capability_PanelInteractionHandler, + "Other" => UI.Capability_Other, + _ => capability, + }; + + /// + /// The same names in the plural, for a group header covering several extensions. Written + /// out rather than derived, because a plural is not a singular with a letter on the end in + /// most of the languages Verso ships in. + /// + private static string CapabilityLabelPlural(string capability) => capability switch + { + "LanguageKernel" => UI.Capability_LanguageKernel_Plural, + "CellRenderer" => UI.Capability_CellRenderer_Plural, + "DataFormatter" => UI.Capability_DataFormatter_Plural, + "CellType" => UI.Capability_CellType_Plural, + "NotebookSerializer" => UI.Capability_NotebookSerializer_Plural, + "Theme" => UI.Capability_Theme_Plural, + "LayoutEngine" => UI.Capability_LayoutEngine_Plural, + "ToolbarAction" => UI.Capability_ToolbarAction_Plural, + "MagicCommand" => UI.Capability_MagicCommand_Plural, + "NotebookPanel" => UI.Capability_NotebookPanel_Plural, + "NotebookPostProcessor" => UI.Capability_NotebookPostProcessor_Plural, + "CellPropertyProvider" => UI.Capability_CellPropertyProvider_Plural, + "ExtensionSettings" => UI.Capability_ExtensionSettings_Plural, + "CellInteractionHandler" => UI.Capability_CellInteractionHandler_Plural, + "LayoutInteractionHandler" => UI.Capability_LayoutInteractionHandler_Plural, + "LayoutLifecycleHandler" => UI.Capability_LayoutLifecycleHandler_Plural, + "PanelInteractionHandler" => UI.Capability_PanelInteractionHandler_Plural, + "Other" => UI.Capability_Other_Plural, + _ => capability, + }; + + /// + /// Fills a sentence's single placeholder with emphasised text. The sentence is one resource + /// rather than the two fragments either side of the emphasis, because which words fall on + /// which side of it is a property of the language. /// - private static string Plural(string label) - => label.EndsWith('s') ? label + "es" : label + "s"; + private static MarkupString Emphasize(string sentence, string value) + => new(string.Format(sentence, $"{WebUtility.HtmlEncode(value)}")); + // Grouped by the raw capability rather than by its name, so which extensions land together + // and in what order does not depend on the language the panel is being read in. private static List>> GroupByCategory( IReadOnlyList infos) { @@ -375,12 +414,11 @@ foreach (var ext in infos) { var primary = ext.Capabilities.Count > 0 ? ext.Capabilities[0] : "Other"; - var displayName = CapabilityDisplayNames.GetValueOrDefault(primary, primary); - if (!groups.TryGetValue(displayName, out var list)) + if (!groups.TryGetValue(primary, out var list)) { list = new List(); - groups[displayName] = list; + groups[primary] = list; } list.Add(ext); } @@ -388,7 +426,7 @@ return groups .OrderBy(g => { - var idx = CategoryOrder.FindIndex(c => CapabilityDisplayNames.GetValueOrDefault(c, c) == g.Key); + var idx = CategoryOrder.IndexOf(g.Key); return idx >= 0 ? idx : CategoryOrder.Count; }) .ToList(); @@ -568,7 +606,7 @@ { 0 => string.Empty, 1 => Service.MarketplaceSources[0], - var n => $"{n} sources" + var n => string.Format(UI.Marketplace_SourceCount, n) }; // ── Marketplace ──────────────────────────────────────────────────── @@ -694,7 +732,7 @@ var result = await Service.InstallExtensionAsync(packageId, version, CancellationToken.None); if (!result.Success) { - _installError = result.ErrorMessage ?? "Installation failed."; + _installError = result.ErrorMessage ?? UI.Marketplace_InstallFailed; } else { @@ -754,7 +792,7 @@ await using var stream = file.OpenReadStream(MaxLocalExtensionBytes); var result = await Service.InstallLocalExtensionAsync(file.Name, stream, CancellationToken.None); if (!result.Success) - _installError = result.ErrorMessage ?? "Installation failed."; + _installError = result.ErrorMessage ?? UI.Marketplace_InstallFailed; else await RefreshSearchResultsAsync(); } diff --git a/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanelHost.razor b/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanelHost.razor index 5af062a5..ab3c1054 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanelHost.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/ExtensionPanelHost.razor @@ -13,7 +13,7 @@ @if (_loading) {
-

Loading...

+

@UI.Common_Loading

} else if (_html is not null) @@ -29,7 +29,7 @@ else {
-

This panel has nothing to show.

+

@UI.Panel_NothingToShow

}
diff --git a/src/Verso.Blazor.Shared/Components/Notebook/MetadataPanel.razor b/src/Verso.Blazor.Shared/Components/Notebook/MetadataPanel.razor index 06648c61..1c8145ba 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/MetadataPanel.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/MetadataPanel.razor @@ -2,16 +2,16 @@ @if (Service.IsLoaded) { diff --git a/src/Verso.Blazor.Shared/Components/Notebook/SettingsPanel.razor b/src/Verso.Blazor.Shared/Components/Notebook/SettingsPanel.razor index 70a2189e..b45c7022 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/SettingsPanel.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/SettingsPanel.razor @@ -12,7 +12,7 @@ @if (visibleDefinitions.Count == 0) {
-

No enabled extensions with configurable settings.

+

@UI.Settings_NoConfigurableExtensions

} else @@ -34,7 +34,7 @@ @if (isOpen) { - var grouped = definitions.OrderBy(d => d.Order).GroupBy(d => d.Category ?? "General"); + var grouped = definitions.OrderBy(d => d.Order).GroupBy(d => d.Category ?? UI.Settings_CategoryGeneral); @foreach (var category in grouped) {
@@ -89,7 +89,7 @@ case SettingType.StringList: break; @@ -113,7 +113,7 @@ else {
-

No notebook is open.

+

@UI.Common_NoNotebookOpen

}
diff --git a/src/Verso.Blazor.Shared/Components/Notebook/Toolbar.razor b/src/Verso.Blazor.Shared/Components/Notebook/Toolbar.razor index 4ae9c262..ca3c42f5 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/Toolbar.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/Toolbar.razor @@ -11,7 +11,7 @@
@DocName - @KernelLabel · @CellCount @(CellCount == 1 ? "cell" : "cells") + @KernelLabel · @CellCountLabel
@@ -29,26 +29,26 @@ @if (!Service.IsEmbedded) {
- -
@if (_showOpenInput) {
- - + + - +
} } @@ -62,10 +62,10 @@ } else @@ -124,7 +124,7 @@ {
@if (_showExportDropdown) { @@ -209,7 +209,7 @@ @onclick:stopPropagation="true" @onkeydown="HandleConfirmKeyDown">
@pending.DisplayName -
@@ -218,7 +218,7 @@
@@ -308,7 +308,7 @@ var name = !string.IsNullOrEmpty(Service.FilePath) ? System.IO.Path.GetFileName(Service.FilePath) : Service.Title; - return string.IsNullOrWhiteSpace(name) ? "Untitled" : name!; + return string.IsNullOrWhiteSpace(name) ? UI.Common_Untitled : name!; } } @@ -334,8 +334,11 @@ private int CellCount => Service.Cells.Count; + private string CellCountLabel => string.Format( + Plural.Of(CellCount, UI.Toolbar_CellCount_One, UI.Toolbar_CellCount_Other), CellCount); + private string PrimaryKernelName => - ResolveLanguageName(Service.DefaultKernelId) ?? "No kernel"; + ResolveLanguageName(Service.DefaultKernelId) ?? UI.Toolbar_NoKernel; // Distinct languages actually in use across code cells (a cell with no // explicit language inherits the notebook default). @@ -366,7 +369,7 @@ var names = DistinctLanguageIds .Select(id => ResolveLanguageName(id) ?? id) .ToList(); - return names.Count > 1 ? "Kernels: " + string.Join(", ", names) : PrimaryKernelName; + return names.Count > 1 ? string.Format(UI.Toolbar_KernelList, string.Join(", ", names)) : PrimaryKernelName; } } @@ -405,11 +408,11 @@ private string KernelStatusText => EffectiveActivity switch { - KernelActivity.Running => "Running…", - KernelActivity.Restarting => "Restarting…", - KernelActivity.Error => "Kernel error", - KernelActivity.Disconnected => "Disconnected", - _ => "Kernel idle" + KernelActivity.Running => UI.Toolbar_KernelRunning, + KernelActivity.Restarting => UI.Toolbar_KernelRestarting, + KernelActivity.Error => UI.Toolbar_KernelError, + KernelActivity.Disconnected => UI.Toolbar_KernelDisconnected, + _ => UI.Toolbar_KernelIdle }; // Error and disconnected states carry the underlying cause (exception message, @@ -527,7 +530,7 @@ } catch (Exception ex) { - await OnError.InvokeAsync($"Stop failed: {ex.Message}"); + await OnError.InvokeAsync(string.Format(UI.Toolbar_StopFailed, ex.Message)); } } @@ -660,7 +663,7 @@ } catch (Exception ex) { - await OnError.InvokeAsync($"{action.DisplayName} failed: {ex.Message}"); + await OnError.InvokeAsync(string.Format(UI.Toolbar_ActionFailed, action.DisplayName, ex.Message)); } } @@ -714,7 +717,7 @@ } catch (Exception ex) { - await OnError.InvokeAsync($"Failed to read file: {ex.Message}"); + await OnError.InvokeAsync(string.Format(UI.Toolbar_ReadFileFailed, ex.Message)); } } diff --git a/src/Verso.Blazor.Shared/Components/Notebook/VariableExplorer.razor b/src/Verso.Blazor.Shared/Components/Notebook/VariableExplorer.razor index 33212932..835afb34 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/VariableExplorer.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/VariableExplorer.razor @@ -1,6 +1,6 @@
- +
@if (_refreshError is not null) @@ -10,16 +10,16 @@ @if (_entries.Count == 0 && _refreshError is null) { -
No variables defined.
+
@UI.Variables_Empty
} else if (_entries.Count > 0) { - - - + + + @@ -109,7 +109,7 @@ } catch (Exception ex) { - _refreshError = $"Refresh failed: {ex.Message}"; + _refreshError = string.Format(UI.Variables_RefreshFailed, ex.Message); } } diff --git a/src/Verso.Blazor.Shared/Components/Notebook/ViewPanel.razor b/src/Verso.Blazor.Shared/Components/Notebook/ViewPanel.razor index c2d6aa4d..a1dd6d26 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/ViewPanel.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/ViewPanel.razor @@ -11,7 +11,7 @@ @if (Service.AvailableLayouts.Count > 1) {
-
Layout
+
@UI.View_Layout
@foreach (var layout in Service.AvailableLayouts) { var l = layout; @@ -27,7 +27,7 @@ @* Worth saying outright: these layouts show the notebook rather than letting you work in it, and that surprises people who switch to one and find they cannot type. *@ - Read only + @UI.View_ReadOnly } @if (isActive) { @@ -41,7 +41,7 @@ @if (ShowThemeSection) {
-
Theme
+
@UI.View_Theme
@foreach (var theme in Service.AvailableThemes) { var t = theme; diff --git a/src/Verso.Blazor.Shared/Components/Notebook/WidgetFrame.razor b/src/Verso.Blazor.Shared/Components/Notebook/WidgetFrame.razor index 143d593f..75495b8c 100644 --- a/src/Verso.Blazor.Shared/Components/Notebook/WidgetFrame.razor +++ b/src/Verso.Blazor.Shared/Components/Notebook/WidgetFrame.razor @@ -8,7 +8,7 @@ title attribute where every other control moved to data-verso-tip. *@ diff --git a/src/Verso.Blazor.Shared/Components/_Imports.razor b/src/Verso.Blazor.Shared/Components/_Imports.razor index 7e057251..b977f553 100644 --- a/src/Verso.Blazor.Shared/Components/_Imports.razor +++ b/src/Verso.Blazor.Shared/Components/_Imports.razor @@ -9,3 +9,4 @@ @using Verso.Blazor.Shared.Components.Editor @using Verso.Blazor.Shared.Services @using Verso.Blazor.Shared.Models +@using Verso.Blazor.Shared.Resources diff --git a/src/Verso.Blazor.Shared/Models/DiffSources.cs b/src/Verso.Blazor.Shared/Models/DiffSources.cs new file mode 100644 index 00000000..0eda38b6 --- /dev/null +++ b/src/Verso.Blazor.Shared/Models/DiffSources.cs @@ -0,0 +1,78 @@ +using Verso.Blazor.Shared.Resources; + +namespace Verso.Blazor.Shared.Models; + +/// +/// The baselines every host can compare a notebook against, and the words for them. +/// +/// +/// Both hosts offer the same four, and both used to spell out the same four names. The +/// server reads the file and runs git itself; the embedded shell asks the editor to do +/// both, because only the editor has the pickers and the repository. What they have in +/// common is exactly this: an id, a name, and a reason it might not be usable. +/// +/// The names live here rather than travelling with the answer the editor sends back. An +/// editor writes its menus in the language its workbench is set to, and a notebook is +/// written in the language its interface is set to, which are two choices a reader is +/// allowed to make differently. The ids are what crosses between them, and they are the +/// same in every language. +/// +/// +public static class DiffSources +{ + /// The copy of the notebook currently on disk. + public const string LastSaved = "lastSaved"; + + /// The copy at the commit version control currently has checked out. + public const string GitHead = "gitHead"; + + /// The copy at a branch, tag, or commit chosen when the comparison runs. + public const string GitRef = "gitRef"; + + /// Another notebook, chosen from disk when the comparison runs. + public const string File = "file"; + + /// + /// What to call a baseline, or when it is not one of the four + /// and only whoever offered it can name it. + /// + /// + /// Resolved on each call rather than held in a table, so a server drawing notebooks + /// for several readers answers each of them in the language they asked for. + /// + public static string? NameOf(string? sourceId) => sourceId switch + { + LastSaved => UI.Compare_SourceLastSaved, + GitHead => UI.Compare_SourceGitHead, + GitRef => UI.Compare_SourceGitRef, + File => UI.Compare_SourceChooseFile, + _ => null, + }; + + /// + /// Why a baseline cannot be compared against, for the two reasons that apply to the + /// four built-in sources, or when neither does. + /// + public static string? UnavailableReason(string? sourceId) => sourceId switch + { + LastSaved => UI.Compare_NotSavedYet, + GitHead or GitRef => UI.Compare_NotInGitRepo, + _ => null, + }; + + /// + /// Names the baseline a comparison actually ran against, for the heading over the + /// change list. + /// + /// Which baseline was used. + /// + /// The part of the name no language can change: the branch, tag, or commit that was + /// chosen, or the name of the file that was. Ignored by the other sources. + /// + public static string ResolvedName(string? sourceId, string? argument) => sourceId switch + { + GitRef => string.Format(UI.Compare_GitRefLabel, argument ?? ""), + File => string.IsNullOrWhiteSpace(argument) ? UI.Compare_Baseline : argument, + _ => NameOf(sourceId) ?? UI.Compare_Baseline, + }; +} diff --git a/src/Verso.Blazor.Shared/Models/HostPanels.cs b/src/Verso.Blazor.Shared/Models/HostPanels.cs index a9118e93..d01d669a 100644 --- a/src/Verso.Blazor.Shared/Models/HostPanels.cs +++ b/src/Verso.Blazor.Shared/Models/HostPanels.cs @@ -1,3 +1,5 @@ +using Verso.Blazor.Shared.Resources; + namespace Verso.Blazor.Shared.Models; /// @@ -30,23 +32,28 @@ public static class HostPanels /// icon at rest, so this is usually the only chance to explain a panel before /// someone decides whether to open it. Each one says what the panel shows rather /// than restating its name, which a tooltip on a one-word control cannot do. + /// + /// Built on each access rather than held in a field. A list built once would keep + /// whichever language happened to be current the first time anything touched it, + /// and on the server that is whoever opened a notebook first. + /// /// - public static readonly IReadOnlyList All = new List + public static IReadOnlyList All => new List { - new("metadata", "", "Metadata", "document", null, 100, IsHostPanel: true, - Description: "Title, kernel, and file details for this notebook."), - new("extensions", "", "Extensions", "puzzle", null, 200, IsHostPanel: true, - Description: "Extensions loaded here, and more you can install."), - new("variables", "", "Variables", "braces", null, 300, IsHostPanel: true, - Description: "Everything the kernel is currently holding."), - new("settings", "", "Settings", "gear", null, 400, IsHostPanel: true, - Description: "Settings contributed by the enabled extensions."), - new(Properties, "", "Properties", "list", null, 500, IsHostPanel: true, - Description: "Settings for the selected cell."), - new(View, "", "View", "layout", null, 600, IsHostPanel: true, - Description: "Switch layout or theme without closing the panel."), - new(Compare, "", "Compare", "compare", null, 700, IsHostPanel: true, - Description: "Measure this notebook against a saved baseline."), + new("metadata", "", UI.Panel_Metadata, "document", null, 100, IsHostPanel: true, + Description: UI.Panel_Metadata_Description), + new("extensions", "", UI.Panel_Extensions, "puzzle", null, 200, IsHostPanel: true, + Description: UI.Panel_Extensions_Description), + new("variables", "", UI.Panel_Variables, "braces", null, 300, IsHostPanel: true, + Description: UI.Panel_Variables_Description), + new("settings", "", UI.Panel_Settings, "gear", null, 400, IsHostPanel: true, + Description: UI.Panel_Settings_Description), + new(Properties, "", UI.Panel_Properties, "list", null, 500, IsHostPanel: true, + Description: UI.Panel_Properties_Description), + new(View, "", UI.Panel_View, "layout", null, 600, IsHostPanel: true, + Description: UI.Panel_View_Description), + new(Compare, "", UI.Panel_Compare, "compare", null, 700, IsHostPanel: true, + Description: UI.Panel_Compare_Description), }; /// diff --git a/src/Verso.Blazor.Shared/Models/PanelDisplayNames.cs b/src/Verso.Blazor.Shared/Models/PanelDisplayNames.cs index 152df585..5ae26207 100644 --- a/src/Verso.Blazor.Shared/Models/PanelDisplayNames.cs +++ b/src/Verso.Blazor.Shared/Models/PanelDisplayNames.cs @@ -1,17 +1,25 @@ +using Verso.Blazor.Shared.Resources; + namespace Verso.Blazor.Shared.Models; /// /// Panel header titles. Extension panels supply their own display name, so this /// covers the host panels and falls back to the raw id for anything else. /// +/// +/// Headers read in capitals, and that is done in the stylesheet rather than here. +/// Changing a word's case is a language-specific operation: Japanese and Chinese have +/// no case at all, and doing it in code with one fixed set of rules gets some languages +/// wrong. The browser knows which language the page is in and can leave the text alone +/// where that is the right answer. +/// public static class PanelDisplayNames { - // The properties panel header reads "CELL PROPERTIES" even though its toggle is - // labelled "Properties", so that one entry keeps its own wording rather than - // deriving from HostPanels. + // The properties panel header says which properties, because a header has room to. + // Its toggle, an icon with a word under it, does not. public static string For(string panelId) => panelId switch { - HostPanels.Properties => "CELL PROPERTIES", - _ => (HostPanels.Find(panelId)?.DisplayName ?? panelId).ToUpperInvariant() + HostPanels.Properties => UI.Panel_PropertiesHeading, + _ => HostPanels.Find(panelId)?.DisplayName ?? panelId }; } diff --git a/src/Verso.Blazor.Shared/Resources/Plural.cs b/src/Verso.Blazor.Shared/Resources/Plural.cs new file mode 100644 index 00000000..0736303d --- /dev/null +++ b/src/Verso.Blazor.Shared/Resources/Plural.cs @@ -0,0 +1,24 @@ +namespace Verso.Blazor.Shared.Resources; + +/// +/// Chooses between the two forms of a message that counts something. +/// +/// +/// Two forms cover every language Verso ships in: German, Spanish, Japanese, and Chinese all +/// need at most a singular and a plural, and the last two need neither. A language with more +/// forms, such as Russian or Polish, does not fit here and would need a real plural selector +/// keyed on the count and the language together. Adding one of those means replacing this, +/// not adding a third key. +/// +/// The alternative, building a word out of a stem and an s, does not survive translation +/// at all: the plural of a German noun is not its singular with a letter on the end. +/// +/// +internal static class Plural +{ + /// Picks the singular for exactly one, and the plural for anything else. + /// How many things the message is about. + /// The message written for a single thing. + /// The message written for any other number of them. + public static string Of(int count, string one, string other) => count == 1 ? one : other; +} diff --git a/src/Verso.Blazor.Shared/Resources/UI.de.resx b/src/Verso.Blazor.Shared/Resources/UI.de.resx new file mode 100644 index 00000000..070222d9 --- /dev/null +++ b/src/Verso.Blazor.Shared/Resources/UI.de.resx @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Neu + + + Neues Notebook + + + Öffnen + + + Datei öffnen + + + Speichern + + + Sie haben ungespeicherte Änderungen. + + \ No newline at end of file diff --git a/src/Verso.Blazor.Shared/Resources/UI.qps-Ploc.resx b/src/Verso.Blazor.Shared/Resources/UI.qps-Ploc.resx new file mode 100644 index 00000000..04a7da20 --- /dev/null +++ b/src/Verso.Blazor.Shared/Resources/UI.qps-Ploc.resx @@ -0,0 +1,922 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + [!!Çéll Ïñtéràçtïòñ Hàñdlér···!!] + + + [!!Çéll Ïñtéràçtïòñ Hàñdlérš···!!] + + + [!!Çéll Pròpértÿ Pròvïdér···!!] + + + [!!Çéll Pròpértÿ Pròvïdérš···!!] + + + [!!Çéll Réñdérér···!!] + + + [!!Çéll Réñdérérš···!!] + + + [!!Çéll Tÿpé···!!] + + + [!!Çéll Tÿpéš···!!] + + + [!!Dàtà Fòrmàttér···!!] + + + [!!Dàtà Fòrmàttérš···!!] + + + [!!Šéttïñgš Pròvïdér···!!] + + + [!!Šéttïñgš Pròvïdérš···!!] + + + [!!Làñgùàgé Kérñél···!!] + + + [!!Làñgùàgé Kérñélš···!!] + + + [!!Làÿòùt Éñgïñé···!!] + + + [!!Làÿòùt Éñgïñéš···!!] + + + [!!Làÿòùt Ïñtéràçtïòñ Hàñdlér···!!] + + + [!!Làÿòùt Ïñtéràçtïòñ Hàñdlérš···!!] + + + [!!Làÿòùt Lïféçÿçlé Hàñdlér···!!] + + + [!!Làÿòùt Lïféçÿçlé Hàñdlérš···!!] + + + [!!Màgïç Çòmmàñd···!!] + + + [!!Màgïç Çòmmàñdš···!!] + + + [!!Pàñél···!!] + + + [!!Pàñélš···!!] + + + [!!Pòšt-Pròçéššòr···!!] + + + [!!Pòšt-Pròçéššòrš···!!] + + + [!!Šérïàlïzér···!!] + + + [!!Šérïàlïzérš···!!] + + + [!!Òthér···!!] + + + [!!Òthér···!!] + + + [!!Pàñél Ïñtéràçtïòñ Hàñdlér···!!] + + + [!!Pàñél Ïñtéràçtïòñ Hàñdlérš···!!] + + + [!!Thémé···!!] + + + [!!Théméš···!!] + + + [!!Tòòlbàr Àçtïòñ···!!] + + + [!!Tòòlbàr Àçtïòñš···!!] + + + [!!+ {0} Çéll···!!] + + + [!!Àñòthér çéll ïš rùññïñg.···!!] + + + [!!Çòllàpšé Çòdé···!!] + + + [!!Çòllàpšé Šéçtïòñ···!!] + + + [!!Délété···!!] + + + [!!Éxpàñd Çòdé···!!] + + + [!!Éxpàñd Šéçtïòñ···!!] + + + [!!Mòvé Dòwñ···!!] + + + [!!Mòvé Ùp···!!] + + + [!!{0} òùtpùt hïddéñ···!!] + + + [!!{0} òùtpùtš hïddéñ···!!] + + + [!!Rùñ···!!] + + + [!!Véršò Ñòtébòòk···!!] + + + [!!Çàñçél···!!] + + + [!!Çlòšé···!!] + + + [!!Dïšmïšš···!!] + + + [!!Lòàdïñg...···!!] + + + [!!Ñò ñòtébòòk ïš òpéñ.···!!] + + + [!!ÒK···!!] + + + [!!Rétrÿ···!!] + + + [!!Šàvé···!!] + + + [!!Štòp···!!] + + + [!!Šùbmït···!!] + + + [!!Ùñtïtléd···!!] + + + [!!Bàçk tò çhàñgéš···!!] + + + [!!Bàšélïñé···!!] + + + [!!çéll {0}···!!] + + + [!!çéll {0} → {1}···!!] + + + [!!Çhàñgé bàšélïñé···!!] + + + [!!Çhàñgéš···!!] + + + [!!Çòmpàré wïth···!!] + + + [!!Thé çòmpàrïšòñ çòùld ñòt rùñ: {0}···!!] + + + [!!Çòmpàré Ñòtébòòk···!!] + + + [!!Çòùld ñòt wòrk òùt whïçh fòldér '{0}' ïš ïñ.···!!] + + + [!!(émptÿ çéll)···!!] + + + [!!'{0}' dòéš ñòt éxïšt.···!!] + + + [!!'{0}' ïš ñòt tràçkéd àt '{1}'. Çòmmït thé fïlé fïršt, òr pïçk à dïfféréñt réf.···!!] + + + [!!gït çòùld ñòt réàd '{0}' àt '{1}'.···!!] + + + [!!Gït: {0}···!!] + + + [!!Lòòkïñg fòr bàšélïñéš...···!!] + + + [!!Ñò çéll çhàñgéd àgàïñšt thïš bàšélïñé.···!!] + + + [!!Thé ñòtébòòk fïlé ïš ñòt ïñšïdé à gït répòšïtòrÿ.···!!] + + + [!!Thé ñòtébòòk hàš ñòt bééñ šàvéd tò à fïlé ÿét.···!!] + + + [!!(ñòt šét)···!!] + + + [!!Ñòtébòòk šéttïñgš···!!] + + + [!!Ñòthïñg ïš béïñg çòmpàréd ÿét. Thé ñòtébòòk ïš ùñtòùçhéd éïthér wàÿ.···!!] + + + [!!Òpéñ fùll dïff···!!] + + + [!!Çòùld ñòt réàd '{0}' àš à ñòtébòòk: {1}···!!] + + + [!!Çhòòšé Fïlé...···!!] + + + [!!Gït: HÉÀD···!!] + + + [!!Gït: Çòmpàré wïth Réf...···!!] + + + [!!Làšt Šàvéd···!!] + + + [!!{0} àddéd···!!] + + + [!!{0} mòdïfïéd···!!] + + + [!!{0} mòvéd···!!] + + + [!!{0} rémòvéd···!!] + + + [!!{0} ùñçhàñgéd···!!] + + + [!!'{0}' ïš ñòt à kñòwñ bràñçh, tàg, òr çòmmït.···!!] + + + [!!wàš çéll {0}···!!] + + + [!!Àppròvé Àll···!!] + + + [!!Ïñštàll···!!] + + + [!!Déñÿ···!!] + + + [!!Thïš ñòtébòòk wàñtš tò lòàd thé fòllòwïñg éxtéñšïòñ pàçkàgé:···!!] + + + [!!Thïš ñòtébòòk wàñtš tò lòàd thé fòllòwïñg éxtéñšïòñ pàçkàgéš:···!!] + + + [!!Thïš ñòtébòòk ñéédš thé fòllòwïñg Pÿthòñ pàçkàgé ïñštàlléd:···!!] + + + [!!Thïš ñòtébòòk ñéédš thé fòllòwïñg Pÿthòñ pàçkàgéš ïñštàlléd:···!!] + + + [!!Ïñtò {0}···!!] + + + [!!Éxtéñšïòñ Çòñšéñt Réqùïréd···!!] + + + [!!Pàçkàgé Ïñštàll Réqùïréd···!!] + + + [!!Éxtéñšïòñš rùñ çòdé òñ ÿòùr màçhïñé. Òñlÿ àppròvé pàçkàgéš ÿòù trùšt.···!!] + + + [!!Pàçkàgéš rùñ çòdé òñ ÿòùr màçhïñé. Òñlÿ ïñštàll pàçkàgéš ÿòù trùšt.···!!] + + + [!!Çòmpàré···!!] + + + [!!/pàth/tò/ñòtébòòk.véršò···!!] + + + [!!màïñ···!!] + + + [!!Pàth tò à ñòtébòòk fïlé (.véršò, .ïpÿñb, .md, .dïb)···!!] + + + [!!Bràñçh, tàg, òr çòmmït···!!] + + + [!!Çòmpàré wïth Fïlé···!!] + + + [!!Çòmpàré wïth Gït Réf···!!] + + + [!!Àppròxïmàté màtçh···!!] + + + [!!àppròxïmàté màtçh···!!] + + + [!!Théšé fïléš çàrrÿ ñò šhàréd çéll ïdéñtïtÿ, šò thïš pàïrïñg ïš bàšéd òñ çòñtéñt šïmïlàrïtÿ.···!!] + + + [!!{0} çhàñgé···!!] + + + [!!{0} çhàñgéš···!!] + + + [!!Çòmpàrïñg wïth {0}···!!] + + + [!!Çùrréñt···!!] + + + [!!Évérÿ çéll àñd évérÿ ñòtébòòk šéttïñg màtçhéš thé bàšélïñé, ïñçlùdïñg ùñšàvéd édïtš.···!!] + + + [!!Ïdéñtïçàl tò {0}···!!] + + + [!!métàdàtà···!!] + + + [!!{0} òf {1}···!!] + + + [!!Ñéxt çhàñgé···!!] + + + [!!Ñò çhàñgéš···!!] + + + [!!Òùtpùtš çhàñgéd···!!] + + + [!!pòšïtïòñ {0} → {1}···!!] + + + [!!Prévïòùš çhàñgé···!!] + + + [!!Šhòw šòùrçé çhàñgéš···!!] + + + [!!{0} → {1}···!!] + + + [!!{0} ùñçhàñgéd çéll···!!] + + + [!!{0} ùñçhàñgéd çéllš···!!] + + + [!!Àddéd···!!] + + + [!!Mòdïfïéd···!!] + + + [!!Mòvéd···!!] + + + [!!Rémòvéd···!!] + + + [!!Ùñçhàñgéd···!!] + + + [!!Çòùld ñòt štòp thé çéll: {0}···!!] + + + [!!Çòùld ñòt çréàté thé ñòtébòòk: {0}···!!] + + + [!!Çòùld ñòt rùñ thé çéll: {0}···!!] + + + [!!thé çòmpàrïšòñ šòùrçéš çòùld ñòt bé lïštéd: {0}···!!] + + + [!!Çòùld ñòt òpéñ thé fïlé: {0}···!!] + + + [!!Çòùld ñòt šàvé: {0}···!!] + + + [!!Çòùld ñòt réçòvér thé šéššïòñ: {0}···!!] + + + [!!ùñkñòwñ érròr···!!] + + + [!!Dïšàblé···!!] + + + [!!Éñàblé···!!] + + + [!!Lòàdéd···!!] + + + [!!Òff···!!] + + + [!!Òñ···!!] + + + [!!Ïñpùt···!!] + + + [!!Ñòtébòòk Ïñpùt···!!] + + + [!!Thé pròçéšš rùññïñg thé kérñélš hàš štòppéd.···!!] + + + [!!Àlt+Dòwñ···!!] + + + [!!Àlt+Ùp···!!] + + + [!!Éšç···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: à fràmé çòùld ñòt bé àllòçàtéd. {0}···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: thé šçrïpt thàt çòññéçtš ït tò Véršò ïš ùñàvàïlàblé. {0}···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: ït àškš tò çòññéçt tò à šòùrçé thàt ïš ñòt àllòwéd ({0}).···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: ñò làÿòùt ïš àçtïvé.···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: ït dïd ñòt répòrt thàt ït wàš réàdÿ wïthïñ {0} šéçòñdš.···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: ïtš réñdérér ïš émptÿ.···!!] + + + [!!Thé làÿòùt çòùld ñòt lòàd: ïtš réñdérér çòùld ñòt bé fétçhéd. {0}···!!] + + + [!!Àddš ñòthïñg···!!] + + + [!!Ït lòàdéd bùt çòñtrïbùtéd ñò éxtéñšïòñ pòïñt, šò ïñštàllïñg ït çhàñgéd ñòthïñg.···!!] + + + [!!Çhòòšé à véršïòñ tò ïñštàll···!!] + + + [!!{0} dòwñlòàdš···!!] + + + [!!Ïñštàll···!!] + + + [!!Ïñštàllàtïòñ fàïléd.···!!] + + + [!!Ïñštàll fròm à fïlé···!!] + + + [!!Šïdélòàd à lòçàl .dll òr .ñùpkg.···!!] + + + [!!Ïñštàllàtïòñ wàš ñòt àppròvéd.···!!] + + + [!!Ïñštàll šçòpé···!!] + + + [!!Ïñštàllïñg réçòrdš thé pàçkàgé ïñ thïš ñòtébòòk, ñòt òñ thïš màçhïñé.···!!] + + + [!!Ïñštàlléd···!!] + + + [!!Ïñštàllš ïñtò {0}···!!] + + + [!!Lòçàl···!!] + + + [!!Šïdélòàdéd fròm à lòçàl fïlé.···!!] + + + [!!lòçàl fïlé···!!] + + + [!!Ñò pàçkàgéš fòùñd.···!!] + + + [!!Ñò éxtéñšïòñš ïñštàlléd. Šéàrçh tò àdd òñé.···!!] + + + [!!Ñòt lòàdéd···!!] + + + [!!Pàçkàgé šòùrçéš···!!] + + + [!!Rémòvé···!!] + + + [!!Rémòvéd fròm thïš ñòtébòòk. Whàt ït lòàdéd štàÿš àçtïvé ùñtïl thé ñòtébòòk ïš réòpéñéd.···!!] + + + [!!fròm {0}···!!] + + + [!!Šéàrçh éxtéñšïòñš...···!!] + + + [!!Šéàrçhïñg...···!!] + + + [!!{0} šòùrçéš···!!] + + + [!!thïš ñòtébòòk···!!] + + + [!!Çréàtéd···!!] + + + [!!Défàùlt Kérñél···!!] + + + [!!Fïlé Pàth···!!] + + + [!!Fòrmàt Véršïòñ···!!] + + + [!!Mòdïfïéd···!!] + + + [!!Tïtlé···!!] + + + [!!(ùñšàvéd)···!!] + + + [!!Šòrrÿ, théré'š ñòthïñg àt thïš àddréšš.···!!] + + + [!!Ñòt fòùñd···!!] + + + [!!Émptÿ ÇŠV···!!] + + + [!!Érròr···!!] + + + [!!{0} ïtém···!!] + + + [!!{0} ïtémš···!!] + + + [!!{0} kéÿ···!!] + + + [!!{0} kéÿš···!!] + + + [!!Thïš òùtpùt çòùld ñòt bé réñdéréd···!!] + + + [!!Rétrÿ réñdérïñg thïš òùtpùt···!!] + + + [!!Štàñdàrd érròr òùtpùt···!!] + + + [!!Wòrkïñg···!!] + + + [!!Çòllàpšé pàñél···!!] + + + [!!Çòmpàré···!!] + + + [!!Méàšùré thïš ñòtébòòk àgàïñšt à šàvéd bàšélïñé.···!!] + + + [!!Éxtéñšïòñš···!!] + + + [!!Éxtéñšïòñš lòàdéd héré, àñd mòré ÿòù çàñ ïñštàll.···!!] + + + [!!Métàdàtà···!!] + + + [!!Tïtlé, kérñél, àñd fïlé détàïlš fòr thïš ñòtébòòk.···!!] + + + [!!Thïš pàñél hàš ñòthïñg tò šhòw.···!!] + + + [!!Pròpértïéš···!!] + + + [!!Çéll Pròpértïéš···!!] + + + [!!Šéttïñgš fòr thé šéléçtéd çéll.···!!] + + + [!!Šéttïñgš···!!] + + + [!!Šéttïñgš çòñtrïbùtéd bÿ thé éñàbléd éxtéñšïòñš.···!!] + + + [!!Vàrïàbléš···!!] + + + [!!Évérÿthïñg thé kérñél ïš çùrréñtlÿ hòldïñg.···!!] + + + [!!Vïéw···!!] + + + [!!Šwïtçh làÿòùt òr thémé wïthòùt çlòšïñg thé pàñél.···!!] + + + [!!Àdd tàg...···!!] + + + [!!Ñò pròpértïéš àvàïlàblé fòr thïš çéll.···!!] + + + [!!Rémòvé tàg···!!] + + + [!!Šéléçt à çéll tò vïéw ïtš pròpértïéš.···!!] + + + [!!Ýòùr šéššïòñ ïš štïll àçtïvé. Àttémptïñg tò réçòññéçt.···!!] + + + [!!Rélòàdïñg tò réštòré ÿòùr ñòtébòòk.···!!] + + + [!!Réçòvérïñg ÿòùr šéššïòñ...···!!] + + + [!!Rélòàd pàgé···!!] + + + [!!Réçòññéçtïñg...···!!] + + + [!!Éñtér thé fùll fïlé pàth. Thé {0} éxtéñšïòñ wïll bé àddéd ïf mïššïñg.···!!] + + + [!!Šàvé tò:···!!] + + + [!!Šàvé Ñòtébòòk Àš···!!] + + + [!!Géñéràl···!!] + + + [!!çòmmà šépàràtéd···!!] + + + [!!Ñò éñàbléd éxtéñšïòñš wïth çòñfïgùràblé šéttïñgš.···!!] + + + [!!Kérñél réštàrtéd···!!] + + + [!!Kérñél réštàrtéd. Ré-rùñ çéllš wïth #!ñùgét òr #!éxtéñšïòñ tò rélòàd.···!!] + + + [!!Làÿòùt '{0}' ïš ñòt lòàdéd. Rùñ thé çéll çòñtàïñïñg #!éxtéñšïòñ òr #!ñùgét tò lòàd ït.···!!] + + + [!!Réqùïréd éxtéñšïòñ {0} çòùld ñòt bé lòàdéd. Šòmé làÿòùtš òr çéllš màÿ ñòt wòrk ùñtïl ït ïš àvàïlàblé.···!!] + + + [!!{0} réqùïréd éxtéñšïòñš çòùld ñòt bé lòàdéd: {1}. Šòmé làÿòùtš òr çéllš màÿ ñòt wòrk ùñtïl théÿ àré àvàïlàblé.···!!] + + + [!!Réštàrtïñg kérñél...···!!] + + + [!!Šàvéd···!!] + + + [!!Šàvéd tò {0}···!!] + + + [!!{0} fàïléd: {1}···!!] + + + [!!Bròwšé···!!] + + + [!!{0} çéll···!!] + + + [!!{0} çéllš···!!] + + + [!!Éxpòrt···!!] + + + [!!Éxpòrt Ñòtébòòk···!!] + + + [!!Gò···!!] + + + [!!Dïšçòññéçtéd···!!] + + + [!!Kérñél érròr···!!] + + + [!!Kérñél ïdlé···!!] + + + [!!Kérñélš: {0}···!!] + + + [!!Réštàrtïñg…···!!] + + + [!!Rùññïñg…···!!] + + + [!!Ñéw···!!] + + + [!!Ñéw Ñòtébòòk···!!] + + + [!!Ñò kérñél···!!] + + + [!!Òpéñ···!!] + + + [!!Òpéñ Fïlé···!!] + + + [!!Fïlé pàth òr ÙRL...···!!] + + + [!!Fàïléd tò réàd fïlé: {0}···!!] + + + [!!Šàvé···!!] + + + [!!Ýòù hàvé ùñšàvéd çhàñgéš.···!!] + + + [!!Štòp Éxéçùtïòñ···!!] + + + [!!Štòp fàïléd: {0}···!!] + + + [!!Ñàmé···!!] + + + [!!Tÿpé···!!] + + + [!!Vàlùé···!!] + + + [!!Ñò vàrïàbléš défïñéd.···!!] + + + [!!Réfréšh···!!] + + + [!!Réfréšh fàïléd: {0}···!!] + + + [!!Làÿòùt···!!] + + + [!!Réàd òñlÿ···!!] + + + [!!Thémé···!!] + + + [!!Çréàté à ñéw ñòtébòòk òr òpéñ àñ éxïštïñg {0}, {1}, òr {2} fïlé tò gét štàrtéd.···!!] + + + [!!Wàïtïñg fòr ñòtébòòk çòñtéñt...···!!] + + + [!!Wïdgét òùtpùt···!!] + + \ No newline at end of file diff --git a/src/Verso.Blazor.Shared/Resources/UI.resx b/src/Verso.Blazor.Shared/Resources/UI.resx new file mode 100644 index 00000000..190508bd --- /dev/null +++ b/src/Verso.Blazor.Shared/Resources/UI.resx @@ -0,0 +1,1209 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cell Interaction Handler + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Cell Interaction Handlers + Plural of “Cell Interaction Handler”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Cell Property Provider + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Cell Property Providers + Plural of “Cell Property Provider”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Cell Renderer + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Cell Renderers + Plural of “Cell Renderer”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Cell Type + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Cell Types + Plural of “Cell Type”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Data Formatter + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Data Formatters + Plural of “Data Formatter”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Settings Provider + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Settings Providers + Plural of “Settings Provider”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Language Kernel + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Language Kernels + Plural of “Language Kernel”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Layout Engine + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Layout Engines + Plural of “Layout Engine”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Layout Interaction Handler + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Layout Interaction Handlers + Plural of “Layout Interaction Handler”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Layout Lifecycle Handler + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Layout Lifecycle Handlers + Plural of “Layout Lifecycle Handler”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Magic Command + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Magic Commands + Plural of “Magic Command”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Panel + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Panels + Plural of “Panel”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Post-Processor + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Post-Processors + Plural of “Post-Processor”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Serializer + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Serializers + Plural of “Serializer”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Other + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Other + Plural of “Other”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Panel Interaction Handler + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Panel Interaction Handlers + Plural of “Panel Interaction Handler”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Theme + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Themes + Plural of “Theme”. Heads a group of extensions in the extensions panel, with a count beside it. + + + Toolbar Action + Names one thing an extension adds to a notebook. Shown as a small chip on a package, so keep it to a couple of words. + + + Toolbar Actions + Plural of “Toolbar Action”. Heads a group of extensions in the extensions panel, with a count beside it. + + + + {0} Cell + Button at the end of the notebook that adds a cell. {0} is the cell type, such as C# or Markdown. Keep the leading plus. + + + Another cell is running. + Second tooltip line on a Run button that is disabled because the kernel is busy elsewhere. + + + Collapse Code + Tooltip on a cell. Clicking hides the editor and leaves the output. + + + Collapse Section + Tooltip on a heading cell. Clicking hides every cell under it until the next heading. + + + Delete + Tooltip on the button that removes this cell from the notebook. + + + Expand Code + Tooltip on a cell whose code is folded away. Clicking shows the editor again. + + + Expand Section + Tooltip on a heading cell whose section is folded away. Clicking shows the cells under it. + + + Move Down + Tooltip on the button that swaps this cell with the one below it. + + + Move Up + Tooltip on the button that swaps this cell with the one above it. + + + {0} output hidden + Stands in for a cell's output while it is hidden. {0} is the number, always 1 here. + + + {0} outputs hidden + Stands in for a cell's outputs while they are hidden. {0} is how many. + + + Run + Tooltip on the button that executes one cell. Keep short. + + + Verso Notebook + The browser tab's title and the heading on the welcome screen. “Verso” is the product's name and is never translated. + + + Cancel + Button that abandons what was being asked. Nothing happens. + + + Close + Tooltip on the close button of a dialog or panel. + + + Dismiss + Button that clears an error banner. Keep it to one word if the language allows. + + + Loading... + Shown while a panel waits for something. Keep the three dots. + + + No notebook is open. + Shown in a side panel when there is nothing for it to describe yet. + + + OK + Button that dismisses a message. The reader has nothing to decide. + + + Retry + Button that tries the same thing again after a failure. + + + Save + Confirming button in a dialog that writes the notebook to a file. + + + Stop + Stops whatever is currently running. Keep short, it sits next to an icon. + + + Submit + Confirming button in a dialog that sends a typed answer back to a running cell. + + + Untitled + Stands in for the name of a notebook that has never been given one. + + + Back to changes + Button that leaves the baseline list and returns to the list of what changed. + + + Baseline + Section heading naming the version currently being compared against. + + + cell {0} + Position of a cell in the notebook. {0} is its number. + + + cell {0} → {1} + Position of a moved cell. {0} is where it was, {1} is where it is now. Keep the arrow. + + + Change baseline + Button that returns to the list of versions to compare against. + + + Changes + Section heading over the list of cells that differ. + + + Compare with + Section heading over the list of versions the notebook can be compared against. + + + The comparison could not run: {0} + Body of that dialog. {0} is the reason, such as a missing baseline or a git error. + + + Compare Notebook + Title of the dialog shown when a comparison against another version of the notebook fails. + + + Could not work out which folder '{0}' is in. + Shown when a notebook's path could not be resolved before comparing. {0} is a path. + + + (empty cell) + Stands in for the first line of a cell that has nothing in it. Keep the brackets, which mark it as a description rather than content. + + + '{0}' does not exist. + Shown when the file chosen as a baseline is not there. {0} is a path. + + + '{0}' is not tracked at '{1}'. Commit the file first, or pick a different ref. + Shown when version control has no copy of the notebook at the chosen point. {0} is a file name and {1} is a branch, tag, or commit. + + + git could not read '{0}' at '{1}'. + Shown when version control failed for a reason Verso does not recognise. {0} is a file name, {1} is a branch, tag, or commit, and “git” is a product name. + + + Git: {0} + Names the baseline in a comparison against version control. {0} is a branch, tag, or commit. + + + Looking for baselines... + Shown while Verso works out which earlier versions it can offer. Keep the three dots. + + + No cell changed against this baseline. + Shown when the two versions hold the same cells. + + + The notebook file is not inside a git repository. + Why a comparison against version control is not available. “git” is a product name and is not translated. + + + The notebook has not been saved to a file yet. + Why a comparison against the saved copy is not available. + + + (not set) + Stands in for a notebook property that had no value on one side of the comparison. Keep the brackets. + + + Notebook settings + Section heading over the notebook's own properties that differ, such as its title or default kernel. + + + Nothing is being compared yet. The notebook is untouched either way. + Reassurance under an empty baseline list. Comparing never modifies the notebook. + + + Open full diff + Button that opens the side-by-side view of both versions. + + + Could not read '{0}' as a notebook: {1} + Shown when a baseline file is not a notebook Verso understands. {0} is a file name and {1} is the underlying reason, which stays in English. + + + Choose File... + Opens a box for typing the path of another notebook. Keep the three dots, which mean a question follows. + + + Git: HEAD + One of the things a notebook can be compared against. “Git” and “HEAD” are version control terms and are not translated. + + + Git: Compare with Ref... + Opens a box for typing a branch, tag, or commit. “Git” and “Ref” are version control terms and are not translated. Keep the three dots, which mean a question follows. + + + Last Saved + One of the things a notebook can be compared against: the copy currently on disk. + + + {0} added + Chip summarising a comparison. {0} is how many cells are new. + + + {0} modified + Chip summarising a comparison. {0} is how many cells differ. + + + {0} moved + Chip summarising a comparison. {0} is how many cells changed position. + + + {0} removed + Chip summarising a comparison. {0} is how many cells are gone. + + + {0} unchanged + Chip summarising a comparison. {0} is how many cells are the same. + + + '{0}' is not a known branch, tag, or commit. + Shown when what was typed does not name anything in version control. {0} is what was typed. + + + was cell {0} + Position of a removed cell, which no longer exists here. {0} is its number in the baseline. + + + Approve All + Button that approves loading every listed extension at once. + + + Install + Button that approves installing the listed Python packages. + + + Deny + Button and close-button label on the consent dialog. Refuses the request and the notebook carries on without whatever was asked for. + + + This notebook wants to load the following extension package: + Opens the consent dialog when exactly one extension is listed. A list of package names follows. + + + This notebook wants to load the following extension packages: + Opens the consent dialog when two or more extensions are listed. A list of package names follows. + + + This notebook needs the following Python package installed: + Opens the consent dialog when exactly one package is listed. A list of package names follows. + + + This notebook needs the following Python packages installed: + Opens the consent dialog when two or more packages are listed. A list of package names follows. + + + Into {0} + Shown under the package list. {0} is where the packages go, such as a virtual environment path. + + + Extension Consent Required + Title of the dialog asking permission to load Verso extensions. + + + Package Install Required + Title of the dialog asking permission to install Python packages. + + + Extensions run code on your machine. Only approve packages you trust. + Warning above the buttons of the extension consent dialog. + + + Packages run code on your machine. Only install packages you trust. + Warning above the buttons of the package consent dialog. + + + Compare + The confirming button in both comparison dialogs. Keep it to one word if the language allows. + + + /path/to/notebook.verso + Greyed-out example in the box. Translate the words in the folder names if that reads better, and keep the .verso extension. + + + main + Greyed-out example in the box. It is the usual name of a version control branch, so leave it as it is. + + + Path to a notebook file (.verso, .ipynb, .md, .dib) + Label above the box in the file comparison dialog. The four file extensions are literal and must not be translated. + + + Branch, tag, or commit + Label above the box in the version control comparison dialog. + + + Compare with File + Title of the dialog that asks which file to compare against. + + + Compare with Git Ref + Title of the dialog that asks which version control reference to compare against. “Git” is a product name and is not translated. + + + Approximate match + Tooltip title on a cell that was paired by similarity rather than identity. + + + approximate match + The marker itself, sitting inline among a cell's other details. Lower case for that reason. + + + These files carry no shared cell identity, so this pairing is based on content similarity. + Tooltip body under “Approximate match”. + + + {0} change + Shown in place of a position before any stepping has happened. {0} is 1. + + + {0} changes + Shown in place of a position before any stepping has happened. {0} is zero or more than one. + + + Comparing with {0} + Title of the full-screen comparison. {0} is what the notebook is being compared against, such as a file name or “Last save”. + + + Current + Heads the right-hand column of results: the notebook as it is now, against the baseline on the left. + + + Every cell and every notebook setting matches the baseline, including unsaved edits. + Explains the “Identical to” heading. + + + Identical to {0} + Heading shown when a comparison found nothing. {0} is what was compared against. + + + metadata + Marker on a cell whose settings changed while its code did not. Lower case; it sits inline among other markers. + + + {0} of {1} + Position within the changes while stepping through them, as in “3 of 12”. + + + Next change + Button that jumps forward to the change after this one. Also its label for screen readers. + + + No changes + Badge in the comparison title when nothing differs. Keep it short. + + + Outputs changed + Expands a cell's before and after results. Names what changed rather than what pressing it does. + + + position {0} → {1} + Beside a moved cell, giving where it was and where it is now. Both are cell numbers. Keep the arrow. + + + Previous change + Button that jumps back to the change before this one. Also its label for screen readers. + + + Show source changes + Button that opens the side-by-side source comparison for one cell. + + + {0} → {1} + Marker on a cell that changed kind or language, as in “code (csharp) → markdown”. Keep the arrow. + + + {0} unchanged cell + Button that expands a run of cells that did not change. {0} is 1. + + + {0} unchanged cells + Button that expands a run of cells that did not change. {0} is more than one. + + + Added + Badge on a cell that is in this version of the notebook and not in the one it is compared against. + + + Modified + Badge on a cell whose contents differ between the two versions. + + + Moved + Badge on a cell that is in both versions but in a different position. + + + Removed + Badge on a cell that is in the compared version and not in this one. + + + Unchanged + Badge on a cell that is the same in both versions. + + + Could not stop the cell: {0} + Error banner. {0} is the underlying reason, which stays in English. + + + Could not create the notebook: {0} + Error banner. {0} is the underlying reason, which stays in English. + + + Could not run the cell: {0} + Error banner. {0} is the underlying reason, which stays in English. + + + the comparison sources could not be listed: {0} + Dropped into “The comparison could not run: ...”, so it starts in lower case and is not a sentence on its own. {0} is the underlying reason, which stays in English. + + + Could not open the file: {0} + Error banner. {0} is the underlying reason, which stays in English. + + + Could not save: {0} + Error banner. {0} is the underlying reason, which stays in English. + + + Could not recover the session: {0} + Error banner shown when reopening a notebook after a disconnection failed. {0} is the underlying reason, which stays in English. + + + unknown error + Stands in for a reason when none was given. It is dropped into a longer sentence, so it starts in lower case. + + + Disable + Label and tooltip for the switch on an enabled extension. Naming what pressing it does. + + + Enable + Label and tooltip for the switch on a disabled extension. Naming what pressing it does. + + + Loaded + Heads the list of extensions this notebook has actually loaded, below the package list. + + + Off + The switch's own text while an extension is disabled. Very tight space, two or three characters. + + + On + The switch's own text while an extension is enabled. Very tight space, two or three characters. + + + Input + Label above the box when a running cell asked a question without wording it. + + + Notebook Input + Title of the dialog a running cell uses to ask the reader a question. + + + The process running the kernels has stopped. + Shown when the separate process Verso runs code in has gone away, and no reason was given. + + + Alt+Down + The keys that step forward a change, shown under a tooltip. Change this only if keyboards for your language print different names on those keys. + + + Alt+Up + The keys that step back a change, shown under a tooltip. Change this only if keyboards for your language print different names on those keys. + + + Esc + The key that closes, shown under a tooltip. Change this only if keyboards for your language print a different name on it. + + + The layout could not load: a frame could not be allocated. {0} + Shown in place of a custom layout. {0} is the underlying error, which stays in English. + + + The layout could not load: the script that connects it to Verso is unavailable. {0} + Shown in place of a custom layout. {0} is the underlying error, which stays in English. + + + The layout could not load: it asks to connect to a source that is not allowed ({0}). + Shown in place of a custom layout. {0} is the address it asked for, and is not translated. + + + The layout could not load: no layout is active. + Shown in place of a custom layout, above a Retry button. + + + The layout could not load: it did not report that it was ready within {0} seconds. + Shown in place of a custom layout that started but never finished. {0} is a whole number of seconds. + + + The layout could not load: its renderer is empty. + Shown in place of a custom layout, above a Retry button. + + + The layout could not load: its renderer could not be fetched. {0} + Shown in place of a custom layout. {0} is the underlying error, which stays in English. + + + Adds nothing + Chip on a package that loaded without contributing anything. Also the tooltip title. Keep it short. + + + It loaded but contributed no extension point, so installing it changed nothing. + Tooltip body under “Adds nothing”. + + + Choose a version to install + Tooltip on the version number beside a package's Install button. + + + {0} downloads + Under a package in the list. {0} is an already shortened count such as 1.2k or 340. + + + Install + Button on a package that is not installed yet. Keep it to one word if the language allows. + + + Installation failed. + Shown when an install failed and reported no reason of its own. + + + Install from a file + Button beside the search box, shown as its label for screen readers and as its tooltip title. + + + Sideload a local .dll or .nupkg. + Tooltip body under “Install from a file”. The two file extensions are literal and must not be translated. + + + Installation was not approved. + Shown after the reader declines the dialog asking whether to install a package. + + + Install scope + Tooltip title on the chip that says where an installed package is recorded. + + + Installing records the package in this notebook, not on this machine. + Tooltip body under “Install scope”. + + + Installed + Chip on a package that is already installed. Keep it short. + + + Installs into {0} + Chip above the package list. {0} is “this notebook”, drawn in bold. Keep it short; the chip sits on one line. + + + Local + Tooltip title on the marker for a package installed from a file rather than a feed. + + + Sideloaded from a local file. + Tooltip body under “Local”. + + + local file + Appears beside a package's author to say it came from a file. Lower case; it sits inside a line of details. + + + No packages found. + Shown in place of the package list when a search returned nothing. + + + No extensions installed. Search to add one. + Shown in place of the package list when nothing is installed and nothing has been searched for. + + + Not loaded + Chip on an installed package that failed to load. The reason is shown underneath. Keep it short. + + + Package sources + Tooltip title on the chip naming where search results came from. + + + Remove + Button on an installed package. Keep it to one word if the language allows. + + + Removed from this notebook. What it loaded stays active until the notebook is reopened. + Notice shown after removing an extension, explaining that removal is recorded now and takes effect later. + + + from {0} + Chip above the package list. {0} is a package source name or a count, drawn in bold. Lower case; it reads as a fragment, not a sentence. + + + Search extensions... + Placeholder in the extensions search box. Typing also filters what is already installed. + + + Searching... + Shown under the search box while a search is running. + + + {0} sources + Stands in for the source name when there is more than one. {0} is always two or more. + + + this notebook + The bold part of “Installs into this notebook”. Lower case because it continues that sentence. + + + Created + Field label. When the notebook was first written. + + + Default Kernel + Field label. Which language new cells use unless a cell says otherwise. + + + File Path + Field label. Where the notebook is saved. + + + Format Version + Field label. Which version of the notebook file format this file uses. + + + Modified + Field label. When the notebook was last written. + + + Title + Field label in the notebook properties panel. The notebook's own title. + + + (unsaved) + Shown in place of a file path when the notebook has never been saved. Keep the brackets. + + + Sorry, there's nothing at this address. + The whole content of the page shown for an address Verso does not serve. + + + Not found + Browser tab title for an address Verso does not serve. + + + Empty CSV + Shown in place of a table when a cell returned comma-separated data with nothing in it. + + + Error + Heading over a cell's error output when the error carried no name of its own. + + + {0} item + Summary of a collapsed JSON list holding exactly one entry. {0} is the number. + + + {0} items + Summary of a collapsed JSON list. {0} is how many entries it holds. + + + {0} key + Summary of a collapsed JSON object holding exactly one entry. {0} is the number. + + + {0} keys + Summary of a collapsed JSON object. {0} is how many entries it holds. + + + This output could not be rendered + Heading of the notice that replaces an output the interface could not draw. + + + Retry rendering this output + Tooltip on the button that tries to draw that output again. + + + Standard error output + Accessible name of the block holding what a cell wrote to standard error. Read aloud by a screen reader. + + + Working + Label on a progress bar whose sender gave it no wording of its own. + + + Collapse panel + Button that closes the open side panel. Also its label for screen readers and its tooltip. + + + Compare + Toggle and heading of the panel for measuring the notebook against a baseline. + + + Measure this notebook against a saved baseline. + Second line of the Compare toggle's tooltip, saying what the panel holds. + + + Extensions + Toggle and heading of the panel listing extensions. + + + Extensions loaded here, and more you can install. + Second line of the Extensions toggle's tooltip, saying what the panel holds. + + + Metadata + Toggle and heading of the panel holding the notebook's own details. + + + Title, kernel, and file details for this notebook. + Second line of the Metadata toggle's tooltip, saying what the panel holds. + + + This panel has nothing to show. + Shown in a panel contributed by an extension when it rendered nothing. + + + Properties + Toggle of the panel holding the selected cell's settings. Its heading is longer; see Panel_PropertiesHeading. + + + Cell Properties + Heading over the open properties panel. Longer than the toggle beside it, which has no room to say which properties. + + + Settings for the selected cell. + Second line of the Properties toggle's tooltip, saying what the panel holds. + + + Settings + Toggle and heading of the panel holding extension settings. + + + Settings contributed by the enabled extensions. + Second line of the Settings toggle's tooltip, saying what the panel holds. + + + Variables + Toggle and heading of the panel listing what the kernel is holding. + + + Everything the kernel is currently holding. + Second line of the Variables toggle's tooltip, saying what the panel holds. + + + View + Toggle and heading of the panel for switching layout or theme. + + + Switch layout or theme without closing the panel. + Second line of the View toggle's tooltip, saying what the panel holds. + + + Add tag... + Placeholder in the field that adds a tag to a cell. Keep the three dots. + + + No properties available for this cell. + Shown in the Properties panel when the selected cell has nothing to configure. + + + Remove tag + Tooltip on the small cross beside a tag in the Properties panel. + + + Select a cell to view its properties. + Shown in the Properties panel when no cell is selected. + + + Your session is still active. Attempting to reconnect. + Under the “Reconnecting” heading, reassuring the reader that nothing has been lost. + + + Reloading to restore your notebook. + Under the “Recovering your session” heading. + + + Recovering your session... + Heading on the overlay once reconnection has been given up on and the page is about to reload. Keep the three dots. + + + Reload page + Button on the reconnection overlay that loads the page again. + + + Reconnecting... + Heading on the overlay shown when the browser loses its connection to Verso. Keep the three dots. + + + Enter the full file path. The {0} extension will be added if missing. + Under the file path box. {0} is the literal text “.verso”, drawn in a code style and not translated. + + + Save to: + Label above the box where a file path is typed. Keep the colon if your language uses one. + + + Save Notebook As + Title of the dialog that asks where to write the notebook. + + + General + Heading over settings an extension did not file under any category of its own. + + + comma separated + Placeholder in a setting that takes several values. Tells the reader how to separate them. Lower case, it sits inside the field. + + + No enabled extensions with configurable settings. + Shown in the Settings panel when nothing that is switched on has anything to configure. + + + Kernel restarted + Brief confirmation after the language kernel was restarted. + + + Kernel restarted. Re-run cells with #!nuget or #!extension to reload. + Shown after a restart when the notebook has cells that load packages. The two commands are literal and must not be translated. + + + Layout '{0}' is not loaded. Run the cell containing #!extension or #!nuget to load it. + Shown when a notebook asks for a layout its extension has not loaded. {0} is the layout's identifier. The two commands are literal and must not be translated. + + + Required extension {0} could not be loaded. Some layouts or cells may not work until it is available. + Shown when the one extension a notebook requires is missing. {0} is a package name. + + + {0} required extensions could not be loaded: {1}. Some layouts or cells may not work until they are available. + Shown when several extensions a notebook requires are missing. {0} is how many, {1} is their names. + + + Restarting kernel... + Shown for as long as the language kernel is restarting. Keep the three dots. + + + Saved + Brief confirmation after writing the notebook, shown for a few seconds. + + + Saved to {0} + Brief confirmation after writing the notebook. {0} is a file name. + + + {0} failed: {1} + Error banner after a toolbar button did not work. {0} is the button's name and {1} is the underlying reason, which stays in English. + + + Browse + Button that opens the system file picker instead of typing a path. + + + {0} cell + Beside the notebook name when it holds exactly one cell. {0} is the number. + + + {0} cells + Beside the notebook name. {0} is how many cells it holds. + + + Export + Label on the Export menu button. Keep short, a menu arrow follows it. + + + Export Notebook + Tooltip on the Export menu. + + + Go + Button that opens whatever was typed in the path field. Keep very short. + + + Disconnected + Kernel status. The kernel process is gone and nothing can run. + + + Kernel error + Kernel status. Something went wrong in the kernel itself, not in a cell. + + + Kernel idle + Kernel status. Ready and waiting, nothing running. + + + Kernels: {0} + Tooltip on a notebook using several languages. {0} is their names, separated by commas. + + + Restarting… + Kernel status. The kernel is starting again and will lose its variables. + + + Running… + Kernel status. A cell is executing. Keep the single ellipsis character. + + + New + Toolbar button label. Creates a new notebook. Keep short, it sits next to an icon. + + + New Notebook + Tooltip for the New toolbar button. + + + No kernel + Shown where the kernel name goes when the notebook names no language to run. + + + Open + Toolbar button label. Opens an existing notebook. Keep short, it sits next to an icon. + + + Open File + Tooltip for the Open toolbar button. + + + File path or URL... + Placeholder in the field that opens a notebook. It takes either. Keep the three dots. + + + Failed to read file: {0} + Error shown when a chosen file could not be read. {0} is the reason. + + + Save + Toolbar button label and its tooltip. Keep short, it sits next to an icon. + + + You have unsaved changes. + Second tooltip line on the Save button, shown only when the notebook is dirty. + + + Stop Execution + Tooltip on the button that appears in place of Run while a cell is running. + + + Stop failed: {0} + Error shown when cancelling a running cell did not work. {0} is the reason. + + + Name + Column heading in the Variables panel. What the variable is called. + + + Type + Column heading in the Variables panel. The variable's data type. + + + Value + Column heading in the Variables panel. A short preview of what the variable holds. + + + No variables defined. + Shown in the Variables panel when the kernel is holding nothing yet. + + + Refresh + Tooltip on the button that asks the kernel for the current variables rather than reading what was last seen. + + + Refresh failed: {0} + Shown in the Variables panel when the kernel could not be asked. {0} is the reason. + + + Layout + Section heading in the View panel, over the list of ways the notebook can be arranged. + + + Read only + Small chip beside a layout that shows the notebook without letting anyone edit it. Keep very short. + + + Theme + Section heading in the View panel, over the list of colour schemes. + + + Create a new notebook or open an existing {0}, {1}, or {2} file to get started. + The welcome screen's one line of guidance. {0}, {1}, and {2} are file extensions drawn in a code style and not translated. + + + Waiting for notebook content... + Shown beside a spinner while the editor waits for a notebook to arrive. Keep the three dots. + + + Widget output + Accessible name of the frame a widget draws itself in. Read aloud by a screen reader. + + \ No newline at end of file diff --git a/src/Verso.Blazor.Shared/Verso.Blazor.Shared.csproj b/src/Verso.Blazor.Shared/Verso.Blazor.Shared.csproj index 0b41762c..50649f31 100644 --- a/src/Verso.Blazor.Shared/Verso.Blazor.Shared.csproj +++ b/src/Verso.Blazor.Shared/Verso.Blazor.Shared.csproj @@ -12,6 +12,29 @@ + + + + MSBuild:Compile + $(IntermediateOutputPath)UI.Designer.cs + CSharp + Verso.Blazor.Shared.Resources + UI + + + + + + + + + diff --git a/src/Verso.Blazor.Wasm/Pages/NotebookPage.razor b/src/Verso.Blazor.Wasm/Pages/NotebookPage.razor index b5899b6c..27a7e597 100644 --- a/src/Verso.Blazor.Wasm/Pages/NotebookPage.razor +++ b/src/Verso.Blazor.Wasm/Pages/NotebookPage.razor @@ -36,7 +36,7 @@ {
@_error - +
} @@ -52,7 +52,7 @@
- Waiting for notebook content... + @UI.Welcome_WaitingForContent
} @@ -85,7 +85,7 @@
@ActivePanelTitle - +
@* Host panels are components with direct access to notebook state; @@ -276,7 +276,7 @@ // Persistent banner — cleared explicitly by HandleKernelRestarted rather // than the 3-second timeout used by ShowStatusAsync. _statusCts?.Cancel(); - _statusMessage = "Restarting kernel..."; + _statusMessage = UI.Status_RestartingKernel; StateHasChanged(); await Task.CompletedTask; }); @@ -295,8 +295,8 @@ || c.Source.Contains("#r \"nuget:", StringComparison.Ordinal))); var message = hasExtensionCells - ? "Kernel restarted. Re-run cells with #!nuget or #!extension to reload." - : "Kernel restarted"; + ? UI.Status_KernelRestartedReload + : UI.Status_KernelRestarted; await ShowStatusAsync(message); }); @@ -306,8 +306,7 @@ { _ = InvokeAsync(async () => { - await ShowStatusAsync( - $"Layout '{layoutId}' not loaded. Run the cell containing #!extension or #!nuget to load it."); + await ShowStatusAsync(string.Format(UI.Status_LayoutNotLoaded, layoutId)); }); } @@ -555,9 +554,11 @@ private NotebookPanelInfo? ActivePanelInfo => _activePanel is null ? null : _panels.FirstOrDefault(p => p.Key == _activePanel); + // Drawn in capitals by the stylesheet, not here: which letters have a capital form, + // and what they turn into, depends on the language the header is written in. private string ActivePanelTitle => ActivePanelInfo is { IsHostPanel: false } extensionPanel - ? extensionPanel.DisplayName.ToUpperInvariant() + ? extensionPanel.DisplayName : PanelDisplayNames.For(ActivePanelInfo?.PanelId ?? _activePanel ?? ""); /// @@ -586,11 +587,11 @@ { _error = null; await Service.SaveAsync(Service.FilePath ?? "notebook.verso"); - await ShowStatusAsync("Saved"); + await ShowStatusAsync(UI.Status_Saved); } catch (Exception ex) { - _error = $"Failed to save: {ex.Message}"; + _error = string.Format(UI.Error_SaveFailed, ex.Message); } } @@ -643,13 +644,13 @@ { await Service.ExecuteCellAsync(cellId); } - catch (Exception ex) when (ex is OperationCanceledException || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase)) + catch (Exception ex) when (IsCancellation(ex)) { // User-initiated cancellation is not an error; the cell's status badge already shows it. } catch (Exception ex) { - _error = $"Execution error: {ex.Message}"; + _error = string.Format(UI.Error_Execution, ex.Message); } finally { @@ -665,7 +666,7 @@ } catch (Exception ex) { - _error = $"Cancel error: {ex.Message}"; + _error = string.Format(UI.Error_Cancel, ex.Message); } } @@ -869,13 +870,13 @@ { await Service.ExecuteAllAsync(); } - catch (Exception ex) when (ex is OperationCanceledException || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase)) + catch (Exception ex) when (IsCancellation(ex)) { // User-initiated cancellation is not an error. } catch (Exception ex) { - _error = $"Execution error: {ex.Message}"; + _error = string.Format(UI.Error_Execution, ex.Message); } finally { @@ -1059,7 +1060,7 @@ } catch (Exception ex) { - _diffErrorMessage = $"could not list comparison sources: {ex.Message}"; + _diffErrorMessage = string.Format(UI.Error_ListComparisonSources, ex.Message); } StateHasChanged(); @@ -1157,6 +1158,20 @@ } } + /// + /// Whether an exception is a cancellation the reader asked for, rather than a fault. + /// + /// + /// Cancellation reaches here wrapped in other exception types as well as on its own, so + /// the inner exception is checked too. The message is the last resort and not the first, + /// because the runtime writes it in the interface language: matching English text would + /// stop recognising a cancellation the moment somebody reads Verso in German. + /// + private static bool IsCancellation(Exception ex) + => ex is OperationCanceledException + || ex.InnerException is OperationCanceledException + || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase); + private void HandleExtensionsUnavailable(IReadOnlyList unavailable) { _ = InvokeAsync(async () => await ShowStatusAsync(FormatUnavailableExtensions(unavailable))); @@ -1165,9 +1180,10 @@ private static string FormatUnavailableExtensions(IReadOnlyList unavailable) { var names = string.Join(", ", unavailable.Select(u => u.PackageId)); - var lead = unavailable.Count == 1 - ? $"Required extension {names} could not be loaded" - : $"{unavailable.Count} required extensions could not be loaded: {names}"; - return $"{lead}. Some layouts or cells may not work until it is available."; + // Two whole sentences rather than a shared tail, so the pronoun at the end can + // agree with the number at the start. + return unavailable.Count == 1 + ? string.Format(UI.Status_RequiredExtensionUnavailable_One, names) + : string.Format(UI.Status_RequiredExtensionUnavailable_Other, unavailable.Count, names); } } diff --git a/src/Verso.Blazor.Wasm/Program.cs b/src/Verso.Blazor.Wasm/Program.cs index 98afc712..7e32c2b8 100644 --- a/src/Verso.Blazor.Wasm/Program.cs +++ b/src/Verso.Blazor.Wasm/Program.cs @@ -15,4 +15,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// The interface language is not set here. WebAssembly fetches satellite assemblies and picks +// its globalization data while the runtime boots, and changing culture afterwards is rejected +// unless the whole ICU dataset is downloaded. So the host page passes the language to +// Blazor.start as applicationCulture instead, and a language change reloads the app. await builder.Build().RunAsync(); diff --git a/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs b/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs index 67379edf..75422f97 100644 --- a/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs +++ b/src/Verso.Blazor.Wasm/Services/RemoteNotebookService.cs @@ -3,6 +3,7 @@ using Verso.Abstractions; using Verso.Blazor.Shared.Models; using Verso.Blazor.Shared.Services; +using Verso.Blazor.Shared.Resources; namespace Verso.Blazor.Wasm.Services; @@ -292,13 +293,33 @@ public async Task> GetDiffSourcesAsync() return (response.Sources ?? new List()) .Select(s => new DiffSourceInfo( s.Id ?? "", - s.Label ?? s.Id ?? "", + SourceLabel(s), s.Kind ?? "", s.Available, - s.Description)) + SourceDescription(s))) .ToList(); } + /// + /// Names one of the baselines a notebook can be compared against. + /// + /// + /// The name the editor sent is a fallback, not the answer. It wrote it in the language + /// its workbench is set to, and this panel is written in the language the notebook + /// interface is set to. Anything the editor offers beyond the four built-in sources is + /// used as it arrived, since nothing here knows what it is. + /// + private static string SourceLabel(DiffSourceItem source) + => DiffSources.NameOf(source.Id) ?? source.Label ?? source.Id ?? ""; + + /// + /// Why a baseline cannot be used, for the two reasons the panel knows how to explain. + /// + private static string? SourceDescription(DiffSourceItem source) + => source.Available || source.Description is null + ? source.Description + : DiffSources.UnavailableReason(source.Id) ?? source.Description; + public async Task ComputeDiffAsync(string sourceId, string? explicitInput = null) { // explicitInput is unused here: the extension resolves gitRef/file inputs with @@ -316,10 +337,22 @@ public async Task> GetDiffSourcesAsync() { baselineContent = baseline.Content, baselineFilePath = baseline.FilePath, - baselineLabel = baseline.Label ?? "Baseline", + baselineLabel = BaselineLabel(baseline), }); } + /// + /// Names the baseline a comparison ran against, for the heading over the change list. + /// + /// + /// The editor picked the baseline and says which one it is; the words for it are + /// chosen here, in the notebook interface language. What travels across is only what + /// no language can change: the git ref that was typed, and the name of a file that was + /// chosen. + /// + private static string BaselineLabel(DiffBaselineResponse baseline) + => DiffSources.ResolvedName(baseline.LabelKind, baseline.LabelArg); + public async Task SaveAsync(string filePath) { // Get the serialized notebook content from the host @@ -1430,7 +1463,7 @@ private void HandleHostExited(string? paramsJson) var detail = TryReadStringProperty(paramsJson, "detail"); OnKernelHealthChanged?.Invoke(new KernelHealthChangedEventArgs( KernelHealth.Disconnected, - detail ?? "The kernel host process exited.")); + detail ?? UI.Kernel_HostExited)); } private void HandleKernelFaulted(string? paramsJson) @@ -2747,6 +2780,13 @@ private sealed class DiffBaselineResponse public bool? Cancelled { get; set; } public string? Content { get; set; } public string? FilePath { get; set; } - public string? Label { get; set; } + + /// Which of the baselines this is, so it can be named here. + public string? LabelKind { get; set; } + + /// + /// The part of the name that is not a word: a git ref, or a file name. + /// + public string? LabelArg { get; set; } } } diff --git a/src/Verso.Blazor.Wasm/_Imports.razor b/src/Verso.Blazor.Wasm/_Imports.razor index fb076b4a..f086f018 100644 --- a/src/Verso.Blazor.Wasm/_Imports.razor +++ b/src/Verso.Blazor.Wasm/_Imports.razor @@ -11,5 +11,6 @@ @using Verso.Blazor.Shared.Components.Editor @using Verso.Blazor.Shared.Services @using Verso.Blazor.Shared.Models +@using Verso.Blazor.Shared.Resources @using Verso @using Verso.Abstractions diff --git a/src/Verso.Blazor.Wasm/wwwroot/index.html b/src/Verso.Blazor.Wasm/wwwroot/index.html index a2908257..3bfddcfb 100644 --- a/src/Verso.Blazor.Wasm/wwwroot/index.html +++ b/src/Verso.Blazor.Wasm/wwwroot/index.html @@ -33,6 +33,19 @@ - + + + diff --git a/src/Verso.Blazor/Components/App.razor b/src/Verso.Blazor/Components/App.razor index 2148e7c6..1a482ad9 100644 --- a/src/Verso.Blazor/Components/App.razor +++ b/src/Verso.Blazor/Components/App.razor @@ -1,7 +1,10 @@ @using System.Reflection +@using System.Globalization +@using System.Text.Json +@using Verso.Blazor.Shared.Resources - + @@ -48,9 +51,9 @@ @@ -66,8 +69,8 @@ let disconnectTimer = null; function recoverAndReload() { - title.textContent = 'Recovering your session...'; - message.textContent = 'Reloading to restore your notebook.'; + title.textContent = @Js(UI.Reconnect_RecoveringTitle); + message.textContent = @Js(UI.Reconnect_RecoveringMessage); reloadBtn.style.display = 'none'; overlay.style.display = 'flex'; @@ -100,8 +103,8 @@ if (Date.now() - pageLoadTime < 10000) return; overlay.style.display = 'flex'; - title.textContent = 'Reconnecting...'; - message.textContent = 'Your session is still active. Attempting to reconnect.'; + title.textContent = @Js(UI.Reconnect_Title); + message.textContent = @Js(UI.Reconnect_Message); reloadBtn.style.display = 'none'; // Foreground fallback: if the tab stays visible, reload after 2 min @@ -136,6 +139,11 @@ @code { + // The overlay is written twice: once as markup for the first paint, and once by the + // script that rewrites it when the connection drops. Both read the same resources, so + // the script needs its text as a quoted JavaScript string rather than as page content. + private static MarkupString Js(string value) => new(JsonSerializer.Serialize(value)); + private static readonly string CacheBuster = GetCacheBuster(); private static string GetCacheBuster() diff --git a/src/Verso.Blazor/Components/DiffSourcePickerDialog.razor b/src/Verso.Blazor/Components/DiffSourcePickerDialog.razor index 0965148a..23a38d0e 100644 --- a/src/Verso.Blazor/Components/DiffSourcePickerDialog.razor +++ b/src/Verso.Blazor/Components/DiffSourcePickerDialog.razor @@ -4,7 +4,7 @@
@@ -47,13 +47,11 @@ private bool IsGitRef => Kind == "gitRef"; - private string Title => IsGitRef ? "Compare with Git Ref" : "Compare with File"; + private string Title => IsGitRef ? UI.DiffPicker_TitleGitRef : UI.DiffPicker_TitleFile; - private string Prompt => IsGitRef - ? "Branch, tag, or commit" - : "Path to a notebook file (.verso, .ipynb, .md, .dib)"; + private string Prompt => IsGitRef ? UI.DiffPicker_PromptGitRef : UI.DiffPicker_PromptFile; - private string Placeholder => IsGitRef ? "main" : "/path/to/notebook.verso"; + private string Placeholder => IsGitRef ? UI.DiffPicker_PlaceholderGitRef : UI.DiffPicker_PlaceholderFile; protected override void OnParametersSet() { diff --git a/src/Verso.Blazor/Components/NotebookInputDialog.razor b/src/Verso.Blazor/Components/NotebookInputDialog.razor index 40d390f1..65f4b5b4 100644 --- a/src/Verso.Blazor/Components/NotebookInputDialog.razor +++ b/src/Verso.Blazor/Components/NotebookInputDialog.razor @@ -5,8 +5,8 @@
- Notebook Input -
@@ -24,10 +24,10 @@
@@ -43,7 +43,7 @@ [Parameter] public EventCallback OnCancel { get; set; } private string PromptText => - string.IsNullOrWhiteSpace(Request?.Prompt) ? "Input" : Request.Prompt; + string.IsNullOrWhiteSpace(Request?.Prompt) ? UI.InputDialog_DefaultPrompt : Request.Prompt; private string InputType => Request?.IsPassword == true ? "password" : "text"; diff --git a/src/Verso.Blazor/Components/Pages/NotebookPage.razor b/src/Verso.Blazor/Components/Pages/NotebookPage.razor index b15d2634..a2925e31 100644 --- a/src/Verso.Blazor/Components/Pages/NotebookPage.razor +++ b/src/Verso.Blazor/Components/Pages/NotebookPage.razor @@ -5,7 +5,7 @@ @inject INotebookService Service @inject IJSRuntime JS -Verso Notebook +@UI.Common_AppTitle @@ -39,29 +39,31 @@
- Save Notebook As -
- + + @* One sentence, one resource: which words fall either side of the file + extension is a property of the language, not of this markup. *@
- Enter the full file path. The .verso extension will be added if missing. + @Sentence(UI.SaveDialog_PathHint, ".verso")
@@ -86,7 +88,7 @@ {
@_error - +
} @@ -100,9 +102,9 @@ @if (!Service.IsLoaded) {
-

Verso Notebook

-

Create a new notebook or open an existing .verso, .ipynb, or .md file to get started.

- +

@UI.Common_AppTitle

+

@Sentence(UI.Welcome_Intro, ".verso", ".ipynb", ".md")

+
} else @@ -134,7 +136,7 @@
@ActivePanelTitle - +
@* Host panels are components with direct access to notebook state; @@ -325,7 +327,7 @@ } catch (Exception ex) { - _error = $"Session recovery failed: {ex.Message}"; + _error = string.Format(UI.Error_SessionRecovery, ex.Message); } } @@ -436,7 +438,7 @@ catch (JSException) { } catch (Exception ex) { - _error = $"Session recovery failed: {ex.Message}"; + _error = string.Format(UI.Error_SessionRecovery, ex.Message); try { await JS.InvokeVoidAsync("versoRecovery.clearState"); } catch { } } } @@ -698,9 +700,11 @@ private NotebookPanelInfo? ActivePanelInfo => _activePanel is null ? null : _panels.FirstOrDefault(p => p.Key == _activePanel); + // Drawn in capitals by the stylesheet, not here: which letters have a capital form, + // and what they turn into, depends on the language the header is written in. private string ActivePanelTitle => ActivePanelInfo is { IsHostPanel: false } extensionPanel - ? extensionPanel.DisplayName.ToUpperInvariant() + ? extensionPanel.DisplayName : PanelDisplayNames.For(ActivePanelInfo?.PanelId ?? _activePanel ?? ""); /// @@ -781,7 +785,7 @@ } catch (Exception ex) { - _error = $"Failed to create notebook: {ex.Message}"; + _error = string.Format(UI.Error_CreateNotebook, ex.Message); } } @@ -802,7 +806,7 @@ } catch (Exception ex) { - _error = $"Failed to open file: {ex.Message}"; + _error = string.Format(UI.Error_OpenFile, ex.Message); } } @@ -833,7 +837,7 @@ } catch (Exception ex) { - _error = $"Failed to open file: {ex.Message}"; + _error = string.Format(UI.Error_OpenFile, ex.Message); } } @@ -872,7 +876,7 @@ _hasNativeHandle = true; if (Service is ServerNotebookService svc) svc.MarkSaved(); - await ShowStatusAsync($"Saved to {result.FileName}"); + await ShowStatusAsync(string.Format(UI.Status_SavedTo, result.FileName)); return; case "cancelled": return; @@ -880,7 +884,7 @@ // Fall through to modal break; default: - _error = $"Failed to save: {result.Message}"; + _error = string.Format(UI.Error_SaveFailed, result.Message); return; } } @@ -908,11 +912,11 @@ path = System.IO.Path.ChangeExtension(path, ".verso"); await Service.SaveAsync(path); - await ShowStatusAsync($"Saved to {System.IO.Path.GetFileName(path)}"); + await ShowStatusAsync(string.Format(UI.Status_SavedTo, System.IO.Path.GetFileName(path))); } catch (Exception ex) { - _error = $"Failed to save: {ex.Message}"; + _error = string.Format(UI.Error_SaveFailed, ex.Message); } } @@ -932,22 +936,24 @@ // clear the unsaved-changes flag here instead. if (Service is ServerNotebookService serverService) serverService.MarkSaved(); - await ShowStatusAsync($"Saved to {result.FileName}"); + await ShowStatusAsync(string.Format(UI.Status_SavedTo, result.FileName)); } else { - _error = $"Failed to save: {result.Message ?? "unknown error"}"; + _error = string.Format(UI.Error_SaveFailed, result.Message ?? UI.Error_Unknown); } } catch (Exception ex) { - _error = $"Failed to save: {ex.Message}"; + _error = string.Format(UI.Error_SaveFailed, ex.Message); } } private string BuildSuggestedFileName() { var title = Service.Title; + // Against the literal, not the resource: a new notebook stores this word in the + // file, so it is data rather than something the reader was shown. var name = string.IsNullOrWhiteSpace(title) || title == "Untitled" ? "notebook" : title; return name + ".verso"; @@ -1039,7 +1045,7 @@ } catch (Exception ex) { - _error = $"Cancel error: {ex.Message}"; + _error = string.Format(UI.Error_Cancel, ex.Message); } } @@ -1061,7 +1067,7 @@ { await executeAsync(); } - catch (Exception ex) when (ex is OperationCanceledException || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase)) + catch (Exception ex) when (IsCancellation(ex)) { // User-initiated cancellation is not an error; the cell's status badge already shows it. } @@ -1069,7 +1075,7 @@ { await InvokeAsync(() => { - _error = $"Execution error: {ex.Message}"; + _error = string.Format(UI.Error_Execution, ex.Message); StateHasChanged(); }); } @@ -1414,7 +1420,7 @@ _selectedCellId = null; if (string.Equals(actionId, "verso.action.restart-kernel", StringComparison.OrdinalIgnoreCase)) - await ShowStatusAsync("Kernel restarted"); + await ShowStatusAsync(UI.Status_KernelRestarted); StateHasChanged(); } @@ -1435,7 +1441,7 @@ } catch (Exception ex) { - _diffErrorMessage = $"could not list comparison sources: {ex.Message}"; + _diffErrorMessage = string.Format(UI.Error_ListComparisonSources, ex.Message); } StateHasChanged(); @@ -1568,6 +1574,30 @@ } } + /// + /// Fills a sentence's placeholders with literal text drawn in a code style. The sentence is + /// one resource rather than the fragments between the placeholders, because word order moves + /// with the language while a file extension does not. + /// + private static MarkupString Sentence(string sentence, params string[] values) + => new(string.Format( + sentence, + values.Select(v => $"{System.Net.WebUtility.HtmlEncode(v)}").ToArray())); + + /// + /// Whether an exception is a cancellation the reader asked for, rather than a fault. + /// + /// + /// Cancellation reaches here wrapped in other exception types as well as on its own, so + /// the inner exception is checked too. The message is the last resort and not the first, + /// because the runtime writes it in the interface language: matching English text would + /// stop recognising a cancellation the moment somebody reads Verso in German. + /// + private static bool IsCancellation(Exception ex) + => ex is OperationCanceledException + || ex.InnerException is OperationCanceledException + || ex.Message.Contains("operation was canceled", StringComparison.OrdinalIgnoreCase); + private void HandleExtensionsUnavailable(IReadOnlyList unavailable) { _ = InvokeAsync(async () => await ShowStatusAsync(FormatUnavailableExtensions(unavailable))); @@ -1576,9 +1606,10 @@ private static string FormatUnavailableExtensions(IReadOnlyList unavailable) { var names = string.Join(", ", unavailable.Select(u => u.PackageId)); - var lead = unavailable.Count == 1 - ? $"Required extension {names} could not be loaded" - : $"{unavailable.Count} required extensions could not be loaded: {names}"; - return $"{lead}. Some layouts or cells may not work until it is available."; + // Two whole sentences rather than a shared tail, so the pronoun at the end can + // agree with the number at the start. + return unavailable.Count == 1 + ? string.Format(UI.Status_RequiredExtensionUnavailable_One, names) + : string.Format(UI.Status_RequiredExtensionUnavailable_Other, unavailable.Count, names); } } diff --git a/src/Verso.Blazor/Components/Routes.razor b/src/Verso.Blazor/Components/Routes.razor index c3971c0d..4b93d2b6 100644 --- a/src/Verso.Blazor/Components/Routes.razor +++ b/src/Verso.Blazor/Components/Routes.razor @@ -4,9 +4,9 @@ - Not found + @UI.NotFound_Title -

Sorry, there's nothing at this address.

+

@UI.NotFound_Message

diff --git a/src/Verso.Blazor/Components/_Imports.razor b/src/Verso.Blazor/Components/_Imports.razor index 79022a8d..e02ae6a9 100644 --- a/src/Verso.Blazor/Components/_Imports.razor +++ b/src/Verso.Blazor/Components/_Imports.razor @@ -14,5 +14,6 @@ @using Verso.Blazor.Shared.Components.Editor @using Verso.Blazor.Shared.Services @using Verso.Blazor.Shared.Models +@using Verso.Blazor.Shared.Resources @using Verso @using Verso.Abstractions diff --git a/src/Verso.Blazor/Localization/VersoLocalization.cs b/src/Verso.Blazor/Localization/VersoLocalization.cs new file mode 100644 index 00000000..6ec2eedd --- /dev/null +++ b/src/Verso.Blazor/Localization/VersoLocalization.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Localization; +using Verso.Localization; + +namespace Verso.Blazor.Localization; + +/// +/// Interface language for the server-hosted notebook UI. +/// +/// +/// Two entry points reach the same application: running Verso.Blazor directly, and +/// verso serve, which builds an equivalent host inside the CLI. They configure this the +/// same way from here rather than each growing their own copy. +/// +public static class VersoLocalization +{ + /// + /// Negotiates the interface language for each request, or pins it when one was asked for. + /// + /// + /// With no explicit language the browser decides through Accept-Language, which is the + /// right answer for a server someone else might open. An explicit language removes the + /// providers so every request gets it, which is the right answer for + /// verso serve --language de. + /// + /// Only the interface language moves. This process runs the kernels, so the formatting + /// culture is pinned to whatever the process started with: a menu language must not decide + /// how a cell's own results are written out. + /// + /// Call this before the components are mapped. A Blazor circuit takes its culture from the + /// request that opened it, so anything registered afterwards arrives too late. + /// + /// The application being configured. + /// A language tag from the command line, or null. + public static WebApplication UseVersoLocalization(this WebApplication app, string? requestedLanguage) + { + var pinned = VersoCultures.TryMatch(requestedLanguage, out var explicitCulture); + var uiCulture = pinned ? explicitCulture : VersoCultures.Resolve(null); + var formattingCulture = CultureInfo.CurrentCulture; + + var options = new RequestLocalizationOptions + { + DefaultRequestCulture = new RequestCulture(formattingCulture, uiCulture), + ApplyCurrentCultureToResponseHeaders = true, + }; + + // One supported formatting culture means header negotiation can never move it: anything a + // browser asks for falls back to the default, which is the process culture. + options.SupportedCultures = new List { formattingCulture }; + options.SupportedUICultures = VersoCultures.Supported + .Select(tag => new CultureInfo(tag)) + .Append(new CultureInfo(VersoCultures.Pseudo)) + .ToList(); + + if (pinned) + options.RequestCultureProviders.Clear(); + + app.UseRequestLocalization(options); + return app; + } +} diff --git a/src/Verso.Blazor/Program.cs b/src/Verso.Blazor/Program.cs index ec9745e1..d0e1f93c 100644 --- a/src/Verso.Blazor/Program.cs +++ b/src/Verso.Blazor/Program.cs @@ -1,3 +1,4 @@ +using Verso.Blazor.Localization; using Verso.Blazor.Services; using Verso.Blazor.Shared.Services; using Verso.Extensions; @@ -37,6 +38,11 @@ } app.UseHttpsRedirection(); + +// Before the components are mapped: a circuit takes its culture from the request that opened it. +// --language pins the interface language; without it the browser's Accept-Language decides. +app.UseVersoLocalization(builder.Configuration["language"]); + app.UseStaticFiles(); app.UseAntiforgery(); diff --git a/src/Verso.Blazor/Services/GitCliHelper.cs b/src/Verso.Blazor/Services/GitCliHelper.cs index 57ae2c06..09f31caa 100644 --- a/src/Verso.Blazor/Services/GitCliHelper.cs +++ b/src/Verso.Blazor/Services/GitCliHelper.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Text; +using Verso.Blazor.Shared.Models; +using Verso.Blazor.Shared.Resources; namespace Verso.Blazor.Services; @@ -40,12 +42,12 @@ public static (string Content, string Label) Show(string filePath, string refNam var directory = Path.GetDirectoryName(fullPath); if (directory is null) { - throw new InvalidOperationException($"Could not resolve the directory of '{filePath}'."); + throw new InvalidOperationException(string.Format(UI.Compare_DirectoryUnresolved, filePath)); } if (FindRepoRoot(directory) is null) { - throw new InvalidOperationException("This notebook is not inside a git repository."); + throw new InvalidOperationException(UI.Compare_NotInGitRepo); } // The "ref:./name" form makes git resolve the repo-relative path itself, from the @@ -58,15 +60,15 @@ public static (string Content, string Label) Show(string filePath, string refNam { var detail = stderr.Contains("exists on disk, but not in", StringComparison.OrdinalIgnoreCase) || stderr.Contains("does not exist in", StringComparison.OrdinalIgnoreCase) - ? $"'{fileName}' is not tracked at '{refName}'. Commit the file first, or pick a different ref." + ? string.Format(UI.Compare_FileNotTracked, fileName, refName) : stderr.Contains("unknown revision", StringComparison.OrdinalIgnoreCase) || stderr.Contains("invalid object name", StringComparison.OrdinalIgnoreCase) - ? $"'{refName}' is not a known branch, tag, or commit." - : $"git could not read '{fileName}' at '{refName}'."; + ? string.Format(UI.Compare_UnknownRef, refName) + : string.Format(UI.Compare_GitReadFailed, fileName, refName); throw new InvalidOperationException(detail); } - return (stdout, $"Git: {refName}"); + return (stdout, DiffSources.ResolvedName(DiffSources.GitRef, refName)); } private static (int ExitCode, string Stdout, string Stderr) Run(string workingDirectory, params string[] arguments) diff --git a/src/Verso.Blazor/Services/ServerNotebookService.Diff.cs b/src/Verso.Blazor/Services/ServerNotebookService.Diff.cs index 11ac2d0f..c321e179 100644 --- a/src/Verso.Blazor/Services/ServerNotebookService.Diff.cs +++ b/src/Verso.Blazor/Services/ServerNotebookService.Diff.cs @@ -2,6 +2,7 @@ using Verso.Blazor.Shared.Models; using Verso.Diffing; using Verso.Serializers; +using Verso.Blazor.Shared.Resources; namespace Verso.Blazor.Services; @@ -18,15 +19,18 @@ public Task> GetDiffSourcesAsync() && Path.GetDirectoryName(Path.GetFullPath(_filePath!)) is { } directory && GitCliHelper.FindRepoRoot(directory) is not null; + // Named through DiffSources so this host and the embedded one offer the same four + // baselines under the same words, and a rename cannot reach one without the other. + static DiffSourceInfo Describe(string id, string kind, bool available) => + new(id, DiffSources.NameOf(id)!, kind, available, + available ? null : DiffSources.UnavailableReason(id)); + IReadOnlyList sources = new List { - new("lastSaved", "Last Saved", "lastSaved", hasFilePath, - hasFilePath ? null : "The notebook has not been saved to a file yet."), - new("gitHead", "Git: HEAD", "git", inGitRepo, - inGitRepo ? null : "The notebook file is not inside a git repository."), - new("gitRef", "Git: Compare with Ref...", "git", inGitRepo, - inGitRepo ? null : "The notebook file is not inside a git repository."), - new("file", "Choose File...", "file", true), + Describe(DiffSources.LastSaved, "lastSaved", hasFilePath), + Describe(DiffSources.GitHead, "git", inGitRepo), + Describe(DiffSources.GitRef, "git", inGitRepo), + Describe(DiffSources.File, "file", true), }; return Task.FromResult(sources); } @@ -35,18 +39,18 @@ public Task> GetDiffSourcesAsync() { if (_scaffold is null) { - throw new InvalidOperationException("No notebook is open."); + throw new InvalidOperationException(UI.Common_NoNotebookOpen); } var (content, baselinePath, label) = sourceId switch { - "lastSaved" => await ReadLastSavedBaselineAsync(), - "gitHead" => ReadGitBaseline("HEAD"), - "gitRef" => ReadGitBaseline( + DiffSources.LastSaved => await ReadLastSavedBaselineAsync(), + DiffSources.GitHead => ReadGitBaseline("HEAD"), + DiffSources.GitRef => ReadGitBaseline( !string.IsNullOrWhiteSpace(explicitInput) ? explicitInput.Trim() : throw new InvalidOperationException("A git ref is required to compare with a ref.")), - "file" => await ReadFileBaselineAsync( + DiffSources.File => await ReadFileBaselineAsync( !string.IsNullOrWhiteSpace(explicitInput) ? explicitInput.Trim() : throw new InvalidOperationException("A file path is required to compare with a file.")), @@ -73,23 +77,23 @@ public Task> GetDiffSourcesAsync() { if (string.IsNullOrEmpty(_filePath)) { - throw new InvalidOperationException("The notebook has not been saved to a file yet."); + throw new InvalidOperationException(UI.Compare_NotSavedYet); } if (!File.Exists(_filePath)) { - throw new InvalidOperationException($"'{_filePath}' does not exist on disk."); + throw new InvalidOperationException(string.Format(UI.Compare_FileMissing, _filePath)); } var content = await File.ReadAllTextAsync(_filePath); - return (content, _filePath, "Last Saved"); + return (content, _filePath, DiffSources.ResolvedName(DiffSources.LastSaved, null)); } private (string Content, string BaselinePath, string Label) ReadGitBaseline(string refName) { if (string.IsNullOrEmpty(_filePath)) { - throw new InvalidOperationException("The notebook has not been saved to a file yet."); + throw new InvalidOperationException(UI.Compare_NotSavedYet); } var (content, label) = GitCliHelper.Show(_filePath, refName); @@ -100,11 +104,11 @@ public Task> GetDiffSourcesAsync() { if (!File.Exists(path)) { - throw new InvalidOperationException($"'{path}' does not exist."); + throw new InvalidOperationException(string.Format(UI.Compare_FileMissing, path)); } var content = await File.ReadAllTextAsync(path); - return (content, path, Path.GetFileName(path)); + return (content, path, DiffSources.ResolvedName(DiffSources.File, Path.GetFileName(path))); } /// @@ -127,7 +131,7 @@ private async Task DeserializeBaselineAsync(string content, strin catch (Exception ex) { throw new InvalidOperationException( - $"Could not parse '{Path.GetFileName(baselinePath)}' as a notebook: {ex.Message}", ex); + string.Format(UI.Compare_ParseFailed, Path.GetFileName(baselinePath), ex.Message), ex); } if (_extensionHost is not null) diff --git a/src/Verso.Blazor/Services/ServerNotebookService.Marketplace.cs b/src/Verso.Blazor/Services/ServerNotebookService.Marketplace.cs index 7c76004c..291ea042 100644 --- a/src/Verso.Blazor/Services/ServerNotebookService.Marketplace.cs +++ b/src/Verso.Blazor/Services/ServerNotebookService.Marketplace.cs @@ -2,6 +2,7 @@ using Verso.Blazor.Shared.Models; using Verso.Extensions; using Verso.Extensions.Marketplace; +using Verso.Blazor.Shared.Resources; namespace Verso.Blazor.Services; @@ -48,7 +49,7 @@ public async Task InstallExtensionAsync( string packageId, string? version, CancellationToken ct) { if (_scaffold is null || _extensionHost is null) - return new PackageInstallResultDto(false, null, "No notebook is open.", 0); + return new PackageInstallResultDto(false, null, UI.Common_NoNotebookOpen, 0); try { @@ -57,7 +58,7 @@ public async Task InstallExtensionAsync( var consent = new List { new(packageId, version, "marketplace") }; var approved = await _extensionHost.RequestExtensionConsentAsync(consent, ct); if (!approved) - return new PackageInstallResultDto(false, null, "Installation was not approved.", 0); + return new PackageInstallResultDto(false, null, UI.Marketplace_InstallNotApproved, 0); _trustStore.Approve(packageId, version); _trustStore.Save(); @@ -106,7 +107,7 @@ public async Task InstallLocalExtensionAsync( string fileName, Stream content, CancellationToken ct) { if (_scaffold is null || _extensionHost is null) - return new PackageInstallResultDto(false, null, "No notebook is open.", 0); + return new PackageInstallResultDto(false, null, UI.Common_NoNotebookOpen, 0); var tempDir = Directory.CreateTempSubdirectory("verso-sideload"); try diff --git a/src/Verso.Blazor/Services/ServerNotebookService.cs b/src/Verso.Blazor/Services/ServerNotebookService.cs index a9e09bcd..75abf973 100644 --- a/src/Verso.Blazor/Services/ServerNotebookService.cs +++ b/src/Verso.Blazor/Services/ServerNotebookService.cs @@ -279,7 +279,9 @@ public IReadOnlyList AvailableCellTypes { get { - var types = new List { new("code", "Code") }; + // Code has no ICellType of its own: it is what a cell is when nothing else claims + // it, so the name comes from the engine's resources rather than a registration. + var types = new List { new("code", Verso.Resources.Strings.CellType_Code) }; if (_extensionHost is null) return types; var hasMarkdown = _extensionHost.GetCellTypes() diff --git a/src/Verso.Cli/Commands/InfoCommand.cs b/src/Verso.Cli/Commands/InfoCommand.cs index 5d050caa..e0ca89de 100644 --- a/src/Verso.Cli/Commands/InfoCommand.cs +++ b/src/Verso.Cli/Commands/InfoCommand.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.Reflection; +using Verso.Cli.Utilities; using Verso.Extensions; namespace Verso.Cli.Commands; diff --git a/src/Verso.Cli/Commands/ServeCommand.cs b/src/Verso.Cli/Commands/ServeCommand.cs index 7200f1a5..8e2db88c 100644 --- a/src/Verso.Cli/Commands/ServeCommand.cs +++ b/src/Verso.Cli/Commands/ServeCommand.cs @@ -58,6 +58,7 @@ public static Command Create() var extensions = context.ParseResult.GetValueForOption(extensionsOption); var verbose = context.ParseResult.GetValueForOption(verboseOption); var preserveFormat = context.ParseResult.GetValueForOption(preserveFormatOption); + var language = context.ParseResult.GetValueForOption(LanguageOption.Instance); PythonInterpreterOption.Apply(context.ParseResult.GetValueForOption(pythonOption)); @@ -82,7 +83,8 @@ public static Command Create() NoHttps = noHttps, Verbose = verbose, ExtensionsDirectory = extensions?.FullName, - PreserveFormat = preserveFormat + PreserveFormat = preserveFormat, + Language = language }; var app = BlazorHostBuilder.Build(options); diff --git a/src/Verso.Cli/Hosting/BlazorHostBuilder.cs b/src/Verso.Cli/Hosting/BlazorHostBuilder.cs index 2b9a2a13..347b0e34 100644 --- a/src/Verso.Cli/Hosting/BlazorHostBuilder.cs +++ b/src/Verso.Cli/Hosting/BlazorHostBuilder.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Verso.Blazor.Localization; using Verso.Blazor.Services; using Verso.Blazor.Shared.Services; using Verso.Extensions; @@ -21,6 +22,11 @@ public sealed record ServeOptions public bool Verbose { get; init; } public string? ExtensionsDirectory { get; init; } public bool PreserveFormat { get; init; } + + /// + /// Interface language to serve, or null to let each browser negotiate one. + /// + public string? Language { get; init; } } /// @@ -113,6 +119,10 @@ public static WebApplication Build(ServeOptions options) if (!options.NoHttps) app.UseHttpsRedirection(); + // Before the components are mapped: a circuit takes its culture from the request that + // opened it. Shared with Verso.Blazor/Program.cs so the two hosts cannot drift apart. + app.UseVersoLocalization(options.Language); + app.UseStaticFiles(); app.UseAntiforgery(); diff --git a/src/Verso.Cli/Program.cs b/src/Verso.Cli/Program.cs index 4d052d19..ffaad843 100644 --- a/src/Verso.Cli/Program.cs +++ b/src/Verso.Cli/Program.cs @@ -4,8 +4,15 @@ using Verso.Cli.Commands; using Verso.Cli.Utilities; +// Ahead of the command tree, because building it reads every description. +LanguageOption.ApplyFromArguments(args); + var rootCommand = new RootCommand("Verso CLI — execute, serve, and convert Verso notebooks."); +// Global rather than per-command: it has to be accepted before a subcommand name so that +// "verso --language de --help" works, and every subcommand reads the same instance. +rootCommand.AddGlobalOption(LanguageOption.Instance); + // Subcommands rootCommand.AddCommand(RunCommand.Create()); rootCommand.AddCommand(InfoCommand.Create()); diff --git a/src/Verso.Cli/Utilities/LanguageOption.cs b/src/Verso.Cli/Utilities/LanguageOption.cs new file mode 100644 index 00000000..50fca809 --- /dev/null +++ b/src/Verso.Cli/Utilities/LanguageOption.cs @@ -0,0 +1,43 @@ +using System.CommandLine; +using Verso.Localization; + +namespace Verso.Cli.Utilities; + +/// +/// The shared --language option. Without it the language comes from +/// VERSO_LANGUAGE, then from the operating system, then English. +/// +public static class LanguageOption +{ + /// + /// The one option instance, registered globally on the root command. + /// + /// + /// Unlike the other shared options, which each command creates its own copy of, this one is + /// global. It has to be accepted before a subcommand name so that verso --language de + /// --help works, and a global option is also the only way for every subcommand to read + /// the same instance out of a parse result. + /// + public static Option Instance { get; } = new( + VersoCultures.Option, + $"Language for messages and help, one of: {string.Join(", ", VersoCultures.Supported)}. " + + "Defaults to the system language, falling back to English."); + + /// + /// Applies the language named in the raw arguments, before anything is parsed. + /// + /// + /// Command and option descriptions are read while the command tree is built, which happens + /// before the parser has looked at anything, so verso --language de --help only works + /// if the language is settled first. That rules out reading it from a parse result, hence the + /// scan. + /// + /// The interface language moves and the formatting culture does not. A notebook run here + /// writes its results to the console and back into the file, so letting a language option + /// change the decimal separator would change the output rather than translate it. + /// + /// + /// The process arguments. + public static void ApplyFromArguments(string[] args) + => VersoCultures.ApplyUiCulture(VersoCultures.Resolve(VersoCultures.FromArguments(args))); +} diff --git a/src/Verso.Host/Handlers/NotebookHandler.cs b/src/Verso.Host/Handlers/NotebookHandler.cs index aa3408bd..558910aa 100644 --- a/src/Verso.Host/Handlers/NotebookHandler.cs +++ b/src/Verso.Host/Handlers/NotebookHandler.cs @@ -380,7 +380,12 @@ private static bool LanguageSupportsCancellation(string languageId) => public static CellTypesResult HandleGetCellTypes(NotebookSession ns) { - var types = new List { new() { Id = "code", DisplayName = "Code" } }; + // Code has no ICellType of its own: it is what a cell is when nothing else claims it, + // so the name comes from the engine's resources rather than a registration. + var types = new List + { + new() { Id = "code", DisplayName = Verso.Resources.Strings.CellType_Code } + }; var extHost = ns.ExtensionHost; diff --git a/src/Verso.Host/Program.cs b/src/Verso.Host/Program.cs index c16a01af..a894ac35 100644 --- a/src/Verso.Host/Program.cs +++ b/src/Verso.Host/Program.cs @@ -3,12 +3,18 @@ using System.Threading.Channels; using Verso.Host; using Verso.Host.Protocol; +using Verso.Localization; // Force UTF-8 for stdin/stdout — Windows defaults to the OEM code page (e.g. CP437) // which corrupts non-ASCII characters in JSON-RPC messages. Console.InputEncoding = Encoding.UTF8; Console.OutputEncoding = Encoding.UTF8; +// Before any kernel loads, so a message produced while an extension is being discovered is +// already in the caller's language. Only the interface language moves: this process runs the +// kernels, and a cell's own results are data rather than chrome. +VersoCultures.ApplyUiCulture(VersoCultures.Resolve(VersoCultures.FromArguments(args))); + // Bind the protocol writer to the real underlying stdout stream, not Console.Out. // Kernels (CSharpKernel, FsiSessionManager) call Console.SetOut to a StringWriter // during cell evaluation so they can capture user stdout. If notifications routed diff --git a/src/Verso/Extensions/CellDisplayPropertyProvider.cs b/src/Verso/Extensions/CellDisplayPropertyProvider.cs index 0f34e208..45716ff3 100644 --- a/src/Verso/Extensions/CellDisplayPropertyProvider.cs +++ b/src/Verso/Extensions/CellDisplayPropertyProvider.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions; @@ -7,10 +8,10 @@ namespace Verso.Extensions; public sealed class CellDisplayPropertyProvider : ICellPropertyProvider { public string ExtensionId => CellViewStateMetadata.ProviderExtensionId; - public string Name => "Cell Display Properties"; + public string Name => Strings.PropertyProvider_Display; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Provides per-cell input and output display settings."; + public string? Description => Strings.PropertyProvider_Display_Description; public int Order => 10; @@ -27,28 +28,28 @@ public Task GetPropertiesSectionAsync(CellModel cell, ICellRend { fields.Add(new( CellViewStateMetadata.InputCollapsedProperty, - "Collapse input", + Strings.Properties_CollapseInput, PropertyFieldType.Toggle, ReadBool(cell, CellViewStateMetadata.InputCollapsedKey))); } fields.Add(new( CellViewStateMetadata.OutputVisibilityProperty, - "Output", + Strings.Properties_Output, PropertyFieldType.Select, ReadOutputVisibility(cell), Options: new[] { - new PropertyFieldOption(CellViewStateMetadata.OutputExpanded, "Full"), - new PropertyFieldOption(CellViewStateMetadata.OutputPreview, "Preview"), - new PropertyFieldOption(CellViewStateMetadata.OutputHidden, "Hidden"), + new PropertyFieldOption(CellViewStateMetadata.OutputExpanded, Strings.Properties_OutputFull), + new PropertyFieldOption(CellViewStateMetadata.OutputPreview, Strings.Properties_OutputPreview), + new PropertyFieldOption(CellViewStateMetadata.OutputHidden, Strings.Properties_OutputHidden), })); if (SupportsInputCollapse(cell)) { fields.Add(new( CellViewStateMetadata.InputPreviewLineCountProperty, - "Input preview lines", + Strings.Properties_InputPreviewLines, PropertyFieldType.Number, ReadPositiveInt( cell, @@ -58,7 +59,7 @@ public Task GetPropertiesSectionAsync(CellModel cell, ICellRend fields.Add(new( CellViewStateMetadata.OutputPreviewLineCountProperty, - "Output preview lines", + Strings.Properties_OutputPreviewLines, PropertyFieldType.Number, ReadPositiveInt( cell, @@ -67,15 +68,15 @@ public Task GetPropertiesSectionAsync(CellModel cell, ICellRend fields.Add(new( CellViewStateMetadata.PreviewStyleProperty, - "Preview style", + Strings.Properties_PreviewStyle, PropertyFieldType.Select, ReadPreviewStyle(cell), Options: new[] { - new PropertyFieldOption(CellViewStateMetadata.PreviewStyleLines, "Lines"), + new PropertyFieldOption(CellViewStateMetadata.PreviewStyleLines, Strings.Properties_PreviewStyleLines), })); - return Task.FromResult(new PropertySection("Display", null, fields)); + return Task.FromResult(new PropertySection(Strings.Properties_DisplaySection, null, fields)); } // Matches Cell.razor's SupportsInputCollapse: only code cells render the gutter chevron diff --git a/src/Verso/Extensions/CellTypes/HtmlCellType.cs b/src/Verso/Extensions/CellTypes/HtmlCellType.cs index 72ed5d40..b89843dd 100644 --- a/src/Verso/Extensions/CellTypes/HtmlCellType.cs +++ b/src/Verso/Extensions/CellTypes/HtmlCellType.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Kernels; using Verso.Extensions.Renderers; @@ -14,10 +15,10 @@ public sealed class HtmlCellType : ICellType // --- IExtension --- public string ExtensionId => "verso.celltype.html"; - public string Name => "HTML Cell Type"; + public string Name => Strings.CellType_Html; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "HTML cell type for authoring raw HTML with @variable substitution."; + public string? Description => Strings.CellType_Html_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -25,6 +26,7 @@ public sealed class HtmlCellType : ICellType // --- ICellType --- public string CellTypeId => "html"; + // Format and product names read the same in every language. public string DisplayName => "HTML"; public string? Icon => "" @@ -35,5 +37,8 @@ public sealed class HtmlCellType : ICellType public ILanguageKernel? Kernel { get; } = new HtmlKernel(); public bool IsEditable => true; + // Starter text, not interface text. What this returns is written into the cell and + // saved with the notebook, so it stays as one language rather than depending on who + // happened to add the cell. public string GetDefaultContent() => "\n

Hello World

"; } diff --git a/src/Verso/Extensions/CellTypes/MarkdownCellType.cs b/src/Verso/Extensions/CellTypes/MarkdownCellType.cs index f3a78ba5..b5c684b2 100644 --- a/src/Verso/Extensions/CellTypes/MarkdownCellType.cs +++ b/src/Verso/Extensions/CellTypes/MarkdownCellType.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Renderers; namespace Verso.Extensions.CellTypes; @@ -14,10 +15,10 @@ public sealed class MarkdownCellType : ICellType // --- IExtension --- public string ExtensionId => "verso.celltype.markdown"; - public string Name => "Markdown Cell Type"; + public string Name => Strings.CellType_Markdown; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Markdown prose cells rendered to HTML with Markdig."; + public string? Description => Strings.CellType_Markdown_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -25,6 +26,7 @@ public sealed class MarkdownCellType : ICellType // --- ICellType --- public string CellTypeId => "markdown"; + // Format and product names read the same in every language. public string DisplayName => "Markdown"; public string? Icon => "" diff --git a/src/Verso/Extensions/CellTypes/MermaidCellType.cs b/src/Verso/Extensions/CellTypes/MermaidCellType.cs index debb6d6c..0d77e5a2 100644 --- a/src/Verso/Extensions/CellTypes/MermaidCellType.cs +++ b/src/Verso/Extensions/CellTypes/MermaidCellType.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Kernels; using Verso.Extensions.Renderers; @@ -14,10 +15,10 @@ public sealed class MermaidCellType : ICellType // --- IExtension --- public string ExtensionId => "verso.celltype.mermaid"; - public string Name => "Mermaid Cell Type"; + public string Name => Strings.CellType_Mermaid; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Mermaid cell type for creating diagrams with mermaid.js syntax."; + public string? Description => Strings.CellType_Mermaid_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -25,6 +26,7 @@ public sealed class MermaidCellType : ICellType // --- ICellType --- public string CellTypeId => "mermaid"; + // Format and product names read the same in every language. public string DisplayName => "Mermaid"; public string? Icon => "" @@ -35,5 +37,8 @@ public sealed class MermaidCellType : ICellType public ILanguageKernel? Kernel { get; } = new MermaidKernel(); public bool IsEditable => true; + // Starter text, not interface text. What this returns is written into the cell and + // saved with the notebook, so it stays as one language rather than depending on who + // happened to add the cell. public string GetDefaultContent() => "graph TD\n A[Start] --> B[End]"; } diff --git a/src/Verso/Extensions/CellTypes/ParametersCellType.cs b/src/Verso/Extensions/CellTypes/ParametersCellType.cs index 1615130b..49632fc5 100644 --- a/src/Verso/Extensions/CellTypes/ParametersCellType.cs +++ b/src/Verso/Extensions/CellTypes/ParametersCellType.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Renderers; namespace Verso.Extensions.CellTypes; @@ -12,16 +13,16 @@ namespace Verso.Extensions.CellTypes; public sealed class ParametersCellType : ICellType { public string ExtensionId => "verso.celltype.parameters"; - public string Name => "Parameters Cell Type"; + public string Name => Strings.CellType_Parameters; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Displays and manages notebook parameter definitions as an interactive form."; + public string? Description => Strings.CellType_Parameters_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; public string CellTypeId => "parameters"; - public string DisplayName => "Parameters"; + public string DisplayName => Strings.CellType_Parameters_Label; public string? Icon => "" + "" diff --git a/src/Verso/Extensions/CellVisibilityPropertyProvider.cs b/src/Verso/Extensions/CellVisibilityPropertyProvider.cs index 50e1d1f0..59dfce4b 100644 --- a/src/Verso/Extensions/CellVisibilityPropertyProvider.cs +++ b/src/Verso/Extensions/CellVisibilityPropertyProvider.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions; @@ -19,10 +20,10 @@ public sealed class CellVisibilityPropertyProvider : ICellPropertyProvider // --- IExtension --- public string ExtensionId => "verso.propertyprovider.visibility"; - public string Name => "Cell Visibility Properties"; + public string Name => Strings.PropertyProvider_Visibility; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Provides per-layout cell visibility overrides in the properties panel."; + public string? Description => Strings.PropertyProvider_Visibility_Description; // --- ICellPropertyProvider --- @@ -62,11 +63,11 @@ public Task GetPropertiesSectionAsync(CellModel cell, ICellRend continue; var currentValue = ReadOverride(cell, layout.LayoutId); - var defaultState = MapHintToDefaultStateName(defaultHint, supported); + var defaultState = DefaultState(defaultHint, supported); var options = supported .OrderBy(s => s) - .Select(s => new PropertyFieldOption(s.ToString().ToLowerInvariant(), FormatStateName(s))) + .Select(s => new PropertyFieldOption(OptionValue(s), FormatStateName(s))) .ToList(); fields.Add(new PropertyField( @@ -74,11 +75,11 @@ public Task GetPropertiesSectionAsync(CellModel cell, ICellRend DisplayName: layout.DisplayName, FieldType: PropertyFieldType.Select, CurrentValue: currentValue, - Description: $"Default: {defaultState}", + Description: string.Format(Strings.Properties_DefaultIs, FormatStateName(defaultState)), Options: options)); } - var section = new PropertySection("Visibility", null, fields); + var section = new PropertySection(Strings.Properties_VisibilitySection, null, fields); return Task.FromResult(section); } @@ -99,10 +100,10 @@ public Task OnPropertyChangedAsync(CellModel cell, string propertyName, object? var layout = layouts.FirstOrDefault(l => l.LayoutId == layoutId); var supported = layout?.SupportedVisibilityStates ?? new HashSet { CellVisibilityState.Visible }; - var defaultStateName = MapHintToDefaultStateName(defaultHint, supported).ToLowerInvariant(); + var defaultStateValue = OptionValue(DefaultState(defaultHint, supported)); var isDefault = string.IsNullOrEmpty(stringValue) || - string.Equals(stringValue, defaultStateName, StringComparison.OrdinalIgnoreCase); + string.Equals(stringValue, defaultStateValue, StringComparison.OrdinalIgnoreCase); // Get or create the visibility dictionary, handling all storage forms: // - Dictionary from in-memory edits @@ -172,25 +173,39 @@ public Task OnPropertyChangedAsync(CellModel cell, string propertyName, object? } } - private static string MapHintToDefaultStateName( + /// + /// The state a cell falls back to under a layout when nothing has been chosen for it. + /// + /// + /// Returns the state itself rather than a name for it. Deciding what the default is and + /// writing it out are two jobs: the first is compared against a stored value, the second + /// is read by a person. Answering both with one string meant comparing a stored + /// outputonly against the words Output Only, which never matched, so an + /// output-only cell kept an override it did not need. + /// + private static CellVisibilityState DefaultState( CellVisibilityHint hint, IReadOnlySet supported) { return hint switch { - CellVisibilityHint.Infrastructure => - supported.Contains(CellVisibilityState.Hidden) ? FormatStateName(CellVisibilityState.Hidden) : FormatStateName(CellVisibilityState.Visible), - CellVisibilityHint.OutputOnly => - supported.Contains(CellVisibilityState.OutputOnly) ? FormatStateName(CellVisibilityState.OutputOnly) : FormatStateName(CellVisibilityState.Visible), - _ => FormatStateName(CellVisibilityState.Visible), + CellVisibilityHint.Infrastructure when supported.Contains(CellVisibilityState.Hidden) => + CellVisibilityState.Hidden, + CellVisibilityHint.OutputOnly when supported.Contains(CellVisibilityState.OutputOnly) => + CellVisibilityState.OutputOnly, + _ => CellVisibilityState.Visible, }; } + // What a state is called on the wire and in cell metadata. Not a display name: this is + // stored in the notebook file and compared against, so it stays the same in every language. + private static string OptionValue(CellVisibilityState state) => state.ToString().ToLowerInvariant(); + private static string FormatStateName(CellVisibilityState state) => state switch { - CellVisibilityState.Visible => "Visible", - CellVisibilityState.Hidden => "Hidden", - CellVisibilityState.OutputOnly => "Output Only", - CellVisibilityState.Collapsed => "Collapsed", + CellVisibilityState.Visible => Strings.Visibility_Visible, + CellVisibilityState.Hidden => Strings.Visibility_Hidden, + CellVisibilityState.OutputOnly => Strings.Visibility_OutputOnly, + CellVisibilityState.Collapsed => Strings.Visibility_Collapsed, _ => state.ToString(), }; } diff --git a/src/Verso/Extensions/Kernels/HtmlKernel.cs b/src/Verso/Extensions/Kernels/HtmlKernel.cs index 3553ca72..1dc17284 100644 --- a/src/Verso/Extensions/Kernels/HtmlKernel.cs +++ b/src/Verso/Extensions/Kernels/HtmlKernel.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Utilities; namespace Verso.Extensions.Kernels; @@ -18,10 +19,10 @@ public sealed class HtmlKernel : ILanguageKernel // --- IExtension --- public string ExtensionId => "verso.kernel.html"; - public string Name => "HTML Kernel"; + public string Name => Strings.Kernel_Html; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Executes HTML cells with @variable substitution."; + public string? Description => Strings.Kernel_Html_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -29,6 +30,7 @@ public sealed class HtmlKernel : ILanguageKernel // --- ILanguageKernel --- public string LanguageId => "html"; + // Format and product names read the same in every language. public string DisplayName => "HTML"; public IReadOnlyList FileExtensions { get; } = new[] { ".html", ".htm" }; diff --git a/src/Verso/Extensions/Kernels/MermaidKernel.cs b/src/Verso/Extensions/Kernels/MermaidKernel.cs index 10bb2a62..38a8bf42 100644 --- a/src/Verso/Extensions/Kernels/MermaidKernel.cs +++ b/src/Verso/Extensions/Kernels/MermaidKernel.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Utilities; namespace Verso.Extensions.Kernels; @@ -16,10 +17,10 @@ public sealed class MermaidKernel : ILanguageKernel // --- IExtension --- public string ExtensionId => "verso.kernel.mermaid"; - public string Name => "Mermaid Kernel"; + public string Name => Strings.Kernel_Mermaid; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Executes Mermaid diagram cells with @variable substitution."; + public string? Description => Strings.Kernel_Mermaid_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -27,6 +28,7 @@ public sealed class MermaidKernel : ILanguageKernel // --- ILanguageKernel --- public string LanguageId => "mermaid"; + // Format and product names read the same in every language. public string DisplayName => "Mermaid"; public IReadOnlyList FileExtensions { get; } = new[] { ".mmd", ".mermaid" }; diff --git a/src/Verso/Extensions/Layouts/ContentFallbackRenderer.cs b/src/Verso/Extensions/Layouts/ContentFallbackRenderer.cs index 31710149..10afdf8b 100644 --- a/src/Verso/Extensions/Layouts/ContentFallbackRenderer.cs +++ b/src/Verso/Extensions/Layouts/ContentFallbackRenderer.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Layouts; @@ -10,13 +11,13 @@ namespace Verso.Extensions.Layouts; internal sealed class ContentFallbackRenderer : ICellRenderer { public string ExtensionId => "verso.internal.fallback-renderer"; - public string Name => "Fallback Renderer"; + public string Name => Strings.Renderer_Fallback; public string Version => "1.0.0"; public string? Author => null; public string? Description => null; public string CellTypeId => ""; - public string DisplayName => "Fallback"; + public string DisplayName => Strings.Renderer_Fallback_Label; public bool CollapsesInputOnExecute => false; public CellVisibilityHint DefaultVisibility => CellVisibilityHint.Content; diff --git a/src/Verso/Extensions/Layouts/DashboardLayout.cs b/src/Verso/Extensions/Layouts/DashboardLayout.cs index d027adbc..7056a51e 100644 --- a/src/Verso/Extensions/Layouts/DashboardLayout.cs +++ b/src/Verso/Extensions/Layouts/DashboardLayout.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Utilities; namespace Verso.Extensions.Layouts; @@ -21,15 +22,15 @@ public sealed class DashboardLayout : ILayoutEngine, ILayoutInteractionHandler // --- IExtension --- public string ExtensionId => "verso.layout.dashboard"; - public string Name => "Dashboard Layout"; + public string Name => Strings.Layout_Dashboard; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Grid-based dashboard layout showing output-only cells."; + public string? Description => Strings.Layout_Dashboard_Description; // --- ILayoutEngine --- public string LayoutId => "dashboard"; - public string DisplayName => "Dashboard"; + public string DisplayName => Strings.Layout_Dashboard_Label; public string? Icon => null; public bool RequiresCustomRenderer => true; @@ -98,11 +99,14 @@ public Task RenderLayoutAsync(IReadOnlyList cells, IVer // Toolbar IS the drag handle: clicking empty space drags, clicking the Run button // fires its data-action via layout-interact. dashboard-interop.js skips drag when the // mousedown target is inside a
") + var dragTip = System.Net.WebUtility.HtmlEncode(Strings.Layout_DragToMove); + sb.Append("
") .Append("") - .Append("") + .Append("\" title=\"").Append(System.Net.WebUtility.HtmlEncode(Strings.Layout_RunCell)) + .Append("\">") + .Append("") .Append("
"); // Resize handle (bottom-right corner). diff --git a/src/Verso/Extensions/Layouts/NotebookLayout.cs b/src/Verso/Extensions/Layouts/NotebookLayout.cs index 28727af3..f8caccee 100644 --- a/src/Verso/Extensions/Layouts/NotebookLayout.cs +++ b/src/Verso/Extensions/Layouts/NotebookLayout.cs @@ -1,5 +1,6 @@ using System.Text; using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Layouts; @@ -23,10 +24,10 @@ public sealed class NotebookLayout : ILayoutEngine, ILayoutInteractionHandler // --- IExtension --- public string ExtensionId => "verso.layout.notebook"; - public string Name => "Notebook Layout"; + public string Name => Strings.Layout_Notebook; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Linear top-to-bottom notebook layout with live, editable cells in elevated cards."; + public string? Description => Strings.Layout_Notebook_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -34,7 +35,7 @@ public sealed class NotebookLayout : ILayoutEngine, ILayoutInteractionHandler // --- ILayoutEngine --- public string LayoutId => "notebook"; - public string DisplayName => "Notebook"; + public string DisplayName => Strings.Layout_Notebook_Label; public string? Icon => null; public bool RequiresCustomRenderer => true; @@ -107,8 +108,8 @@ public Task RenderLayoutAsync(IReadOnlyList cells, IVer if (cells.Count == 0) { sb.Append("
") - .Append("

This notebook is empty

") - .Append("

Add your first cell below.

") + .Append("

").Append(Escape(Strings.Layout_EmptyTitle)).Append("

") + .Append("

").Append(Escape(Strings.Layout_EmptySubtitle)).Append("

") .Append("
"); } @@ -160,7 +161,8 @@ public Task RenderLayoutAsync(IReadOnlyList cells, IVer .Append(cells.Count) .Append("\" data-type=\"").Append(ct.Id).Append("\">") .Append("+ ") - .Append(ct.DisplayName).Append(" Cell"); + .Append(Escape(string.Format(Strings.Layout_AddCell, ct.DisplayName))) + .Append(""); } sb.Append("
"); @@ -214,6 +216,11 @@ public async Task OnLayoutInteractionAsync(LayoutInteractionContext context) // --- HTML helpers --- + // Text drawn into the layout's own markup passes through here first. Cell type names reach + // this file from extensions, and the words around them from a resource file, so neither is + // guaranteed to be free of characters that would otherwise close a tag or an attribute. + private static string Escape(string value) => System.Net.WebUtility.HtmlEncode(value); + private static void AppendInsertRail(StringBuilder sb, int index, IReadOnlyList cellTypes) { sb.Append("
"); @@ -222,9 +229,11 @@ private static void AppendInsertRail(StringBuilder sb, int index, IReadOnlyList< sb.Append(""); + .Append(Escape(ct.DisplayName)).Append(""); } sb.Append("
"); } @@ -236,7 +245,9 @@ private static void AppendInsertRail(StringBuilder sb, int index, IReadOnlyList< ///
private static IReadOnlyList GetAvailableCellTypes(IVersoContext context) { - var types = new List { new("code", "Code") }; + // Code has no ICellType of its own: it is what a cell is when nothing else claims it, + // so the engine has to name it here rather than reading the name off a registration. + var types = new List { new("code", Strings.CellType_Code) }; var host = context.ExtensionHost; var registeredTypes = host.GetCellTypes(); diff --git a/src/Verso/Extensions/Layouts/PresentationLayout.cs b/src/Verso/Extensions/Layouts/PresentationLayout.cs index c0bbef27..20bbe66d 100644 --- a/src/Verso/Extensions/Layouts/PresentationLayout.cs +++ b/src/Verso/Extensions/Layouts/PresentationLayout.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Utilities; namespace Verso.Extensions.Layouts; @@ -16,15 +17,15 @@ public sealed class PresentationLayout : ILayoutEngine // --- IExtension --- public string ExtensionId => "verso.layout.presentation"; - public string Name => "Presentation Layout"; + public string Name => Strings.Layout_Presentation; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Output-only presentation layout for consuming interactive notebooks."; + public string? Description => Strings.Layout_Presentation_Description; // --- ILayoutEngine --- public string LayoutId => "presentation"; - public string DisplayName => "Presentation"; + public string DisplayName => Strings.Layout_Presentation_Label; public string? Icon => null; public LayoutCapabilities Capabilities => LayoutCapabilities.None; diff --git a/src/Verso/Extensions/Renderers/HtmlCellRenderer.cs b/src/Verso/Extensions/Renderers/HtmlCellRenderer.cs index b7f01848..a95ae157 100644 --- a/src/Verso/Extensions/Renderers/HtmlCellRenderer.cs +++ b/src/Verso/Extensions/Renderers/HtmlCellRenderer.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Renderers; @@ -11,14 +12,15 @@ public sealed class HtmlCellRenderer : ICellRenderer // --- IExtension --- public string ExtensionId => "verso.renderer.html"; - public string Name => "HTML Renderer"; + public string Name => Strings.Renderer_Html; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Renders HTML cells with input collapse on execute."; + public string? Description => Strings.Renderer_Html_Description; // --- ICellRenderer --- public string CellTypeId => "html"; + // Format and product names read the same in every language. public string DisplayName => "HTML"; public bool CollapsesInputOnExecute => true; diff --git a/src/Verso/Extensions/Renderers/MarkdownRenderer.cs b/src/Verso/Extensions/Renderers/MarkdownRenderer.cs index 35a5ea05..b9d31f84 100644 --- a/src/Verso/Extensions/Renderers/MarkdownRenderer.cs +++ b/src/Verso/Extensions/Renderers/MarkdownRenderer.cs @@ -1,5 +1,6 @@ using Markdig; using Verso.Abstractions; +using Verso.Resources; using Verso.Extensions.Utilities; namespace Verso.Extensions.Renderers; @@ -17,14 +18,15 @@ public sealed class MarkdownRenderer : ICellRenderer // --- IExtension --- public string ExtensionId => "verso.renderer.markdown"; - public string Name => "Markdown Renderer"; + public string Name => Strings.Renderer_Markdown; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Renders Markdown cells using Markdig."; + public string? Description => Strings.Renderer_Markdown_Description; // --- ICellRenderer --- public string CellTypeId => "markdown"; + // Format and product names read the same in every language. public string DisplayName => "Markdown"; public bool CollapsesInputOnExecute => true; diff --git a/src/Verso/Extensions/Renderers/MermaidCellRenderer.cs b/src/Verso/Extensions/Renderers/MermaidCellRenderer.cs index 83ba177c..ba206d9c 100644 --- a/src/Verso/Extensions/Renderers/MermaidCellRenderer.cs +++ b/src/Verso/Extensions/Renderers/MermaidCellRenderer.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Renderers; @@ -11,14 +12,15 @@ public sealed class MermaidCellRenderer : ICellRenderer // --- IExtension --- public string ExtensionId => "verso.renderer.mermaid"; - public string Name => "Mermaid Renderer"; + public string Name => Strings.Renderer_Mermaid; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Renders Mermaid diagram cells with input collapse on execute."; + public string? Description => Strings.Renderer_Mermaid_Description; // --- ICellRenderer --- public string CellTypeId => "mermaid"; + // Format and product names read the same in every language. public string DisplayName => "Mermaid"; public bool CollapsesInputOnExecute => true; diff --git a/src/Verso/Extensions/Renderers/ParametersCellRenderer.cs b/src/Verso/Extensions/Renderers/ParametersCellRenderer.cs index 60a61f61..11f09880 100644 --- a/src/Verso/Extensions/Renderers/ParametersCellRenderer.cs +++ b/src/Verso/Extensions/Renderers/ParametersCellRenderer.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json; using Verso.Abstractions; +using Verso.Resources; using Verso.Parameters; namespace Verso.Extensions.Renderers; @@ -18,13 +19,13 @@ public sealed class ParametersCellRenderer : ICellRenderer, ICellInteractionHand PropertyNameCaseInsensitive = true }; public string ExtensionId => "verso.renderer.parameters"; - public string Name => "Parameters Renderer"; + public string Name => Strings.Renderer_Parameters; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Renders parameter definitions as an interactive form with type-aware inputs."; + public string? Description => Strings.Renderer_Parameters_Description; public string CellTypeId => "parameters"; - public string DisplayName => "Parameters"; + public string DisplayName => Strings.CellType_Parameters_Label; public bool CollapsesInputOnExecute => false; public CellVisibilityHint DefaultVisibility => CellVisibilityHint.Infrastructure; @@ -59,6 +60,12 @@ public Task RenderOutputAsync(CellOutput output, ICellRenderContex } // --- Interaction handlers --- + // + // The messages naming a malformed payload stay in English on purpose. They can only appear + // when the front end sends something this renderer cannot read, which is a fault in the + // code rather than anything the reader did, and the wording is what somebody searches for + // when they report it. Everything a reader can actually cause, such as a value that does + // not fit its type, is translated. private static string HandleParameterUpdate(CellInteractionContext context) { @@ -68,10 +75,10 @@ private static string HandleParameterUpdate(CellInteractionContext context) var parameters = context.NotebookModel?.Parameters; if (parameters is null || !parameters.TryGetValue(payload.Name, out var def)) - return RenderError($"Parameter '{payload.Name}' not found."); + return RenderError(string.Format(Strings.Parameters_NotFound, payload.Name)); if (!ParameterValueParser.TryParse(def.Type, payload.Value ?? "", out var typed, out var error)) - return RenderErrorForField(payload.Name, error ?? "Invalid value."); + return RenderErrorForField(payload.Name, error ?? Strings.Parameters_InvalidValue); def.Default = typed; context.Variables?.Set(payload.Name, typed!); @@ -130,7 +137,7 @@ private static string HandleParameterRemove(CellInteractionContext context) var parameters = context.NotebookModel?.Parameters; if (parameters is null) - return RenderError("No parameters defined."); + return RenderError(Strings.Parameters_None); parameters.Remove(payload.Name); context.Variables?.Remove(payload.Name); @@ -147,7 +154,7 @@ private static string HandleParameterSubmit(CellInteractionContext context) var parameters = context.NotebookModel?.Parameters; if (parameters is null) - return RenderError("No parameters defined."); + return RenderError(Strings.Parameters_None); var errors = new Dictionary(); var parsed = new Dictionary(); @@ -163,7 +170,7 @@ private static string HandleParameterSubmit(CellInteractionContext context) if (!ParameterValueParser.TryParse(def.Type, value, out var typed, out var error)) { - errors[name] = error ?? "Invalid value."; + errors[name] = error ?? Strings.Parameters_InvalidValue; continue; } @@ -190,7 +197,7 @@ private static string HandleToggleRequired(CellInteractionContext context) var parameters = context.NotebookModel?.Parameters; if (parameters is null || !parameters.TryGetValue(payload.Name, out var def)) - return RenderError($"Parameter '{payload.Name}' not found."); + return RenderError(string.Format(Strings.Parameters_NotFound, payload.Name)); def.Required = string.Equals(payload.Value, "true", StringComparison.OrdinalIgnoreCase); context.StateChanged = true; @@ -233,7 +240,7 @@ private static string RenderEmptyState() { var sb = new StringBuilder(); sb.Append("
"); - sb.Append("

No parameters defined.

"); + sb.Append("

").Append(Encode(Strings.Parameters_None)).Append("

"); // Hidden inline form (same structure as RenderExpandedForm) so the // JS handler for data-action="parameter-add" can reveal it. @@ -241,7 +248,8 @@ private static string RenderEmptyState() sb.Append("
"); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append("
NameTypeValue@UI.Variables_ColumnName@UI.Variables_ColumnType@UI.Variables_ColumnValue
"); - sb.Append(""); + sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); - sb.Append(""); - sb.Append(""); + sb.Append(""); + sb.Append(""); sb.Append("
"); - sb.Append(""); + sb.Append(""); sb.Append("
"); return sb.ToString(); } @@ -283,7 +297,7 @@ private static string RenderExpandedForm( var sb = new StringBuilder(); sb.Append("
"); sb.Append("
"); - sb.Append("Parameters"); + sb.Append("").Append(Encode(Strings.Parameters_Title)).Append(""); sb.Append("("); sb.Append(sorted.Count); sb.Append(")"); @@ -291,12 +305,17 @@ private static string RenderExpandedForm( if (submitted) { - sb.Append("
Parameters applied successfully.
"); + sb.Append("
").Append(Encode(Strings.Parameters_Applied)).Append("
"); } sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); sb.Append(""); @@ -348,7 +367,7 @@ private static string RenderExpandedForm( // Remove button sb.Append(""); + sb.Append("\" title=\"").Append(Encode(Strings.Parameters_Remove)).Append("\">✕"); sb.Append(""); } @@ -356,7 +375,8 @@ private static string RenderExpandedForm( // Inline "add parameter" row (hidden by default, shown via JS) sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append("
NameTypeDescriptionDefaultRequired").Append(Encode(Strings.Parameters_ColumnName)) + .Append("").Append(Encode(Strings.Parameters_ColumnType)) + .Append("").Append(Encode(Strings.Parameters_ColumnDescription)) + .Append("").Append(Encode(Strings.Parameters_ColumnDefault)) + .Append("").Append(Encode(Strings.Parameters_ColumnRequired)) + .Append("
"); - sb.Append(""); + sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); - sb.Append(""); + sb.Append(""); sb.Append(""); - sb.Append(""); - sb.Append(""); + sb.Append(""); + sb.Append(""); sb.Append("
"); sb.Append("
"); - sb.Append(""); + sb.Append(""); sb.Append("
"); sb.Append("
"); diff --git a/src/Verso/Extensions/Themes/VersoDarkTheme.cs b/src/Verso/Extensions/Themes/VersoDarkTheme.cs index 508f004d..bc47974d 100644 --- a/src/Verso/Extensions/Themes/VersoDarkTheme.cs +++ b/src/Verso/Extensions/Themes/VersoDarkTheme.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Themes; @@ -11,15 +12,15 @@ public sealed class VersoDarkTheme : ITheme // --- IExtension --- public string ExtensionId => "verso.theme.dark"; - public string Name => "Verso Dark"; + public string Name => Strings.Theme_Dark; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Default dark theme for Verso notebooks."; + public string? Description => Strings.Theme_Dark_Description; // --- ITheme --- public string ThemeId => "verso-dark"; - public string DisplayName => "Verso Dark"; + public string DisplayName => Strings.Theme_Dark; public ThemeKind ThemeKind => ThemeKind.Dark; public ThemeColorTokens Colors { get; } = new ThemeColorTokens diff --git a/src/Verso/Extensions/Themes/VersoHighContrastTheme.cs b/src/Verso/Extensions/Themes/VersoHighContrastTheme.cs index ff8b044c..8f1a93be 100644 --- a/src/Verso/Extensions/Themes/VersoHighContrastTheme.cs +++ b/src/Verso/Extensions/Themes/VersoHighContrastTheme.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Themes; @@ -12,15 +13,15 @@ public sealed class VersoHighContrastTheme : ITheme // --- IExtension --- public string ExtensionId => "verso.theme.highcontrast"; - public string Name => "Verso High Contrast"; + public string Name => Strings.Theme_HighContrast; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "High-contrast accessibility theme with WCAG 2.1 AA compliant color tokens."; + public string? Description => Strings.Theme_HighContrast_Description; // --- ITheme --- public string ThemeId => "verso-highcontrast"; - public string DisplayName => "Verso High Contrast"; + public string DisplayName => Strings.Theme_HighContrast; public ThemeKind ThemeKind => ThemeKind.HighContrast; public ThemeColorTokens Colors { get; } = new ThemeColorTokens diff --git a/src/Verso/Extensions/Themes/VersoLightTheme.cs b/src/Verso/Extensions/Themes/VersoLightTheme.cs index bcb06a2c..8e152b0c 100644 --- a/src/Verso/Extensions/Themes/VersoLightTheme.cs +++ b/src/Verso/Extensions/Themes/VersoLightTheme.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.Themes; @@ -11,15 +12,15 @@ public sealed class VersoLightTheme : ITheme // --- IExtension --- public string ExtensionId => "verso.theme.light"; - public string Name => "Verso Light"; + public string Name => Strings.Theme_Light; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Default light theme for Verso notebooks."; + public string? Description => Strings.Theme_Light_Description; // --- ITheme --- public string ThemeId => "verso-light"; - public string DisplayName => "Verso Light"; + public string DisplayName => Strings.Theme_Light; public ThemeKind ThemeKind => ThemeKind.Light; public ThemeColorTokens Colors { get; } = new(); diff --git a/src/Verso/Extensions/ToolbarActions/ClearCellOutputAction.cs b/src/Verso/Extensions/ToolbarActions/ClearCellOutputAction.cs index 05260387..41579ecb 100644 --- a/src/Verso/Extensions/ToolbarActions/ClearCellOutputAction.cs +++ b/src/Verso/Extensions/ToolbarActions/ClearCellOutputAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -6,16 +7,16 @@ namespace Verso.Extensions.ToolbarActions; public sealed class ClearCellOutputAction : IToolbarAction { public string ExtensionId => "verso.action.clear-cell-output"; - public string Name => "Clear Cell Output"; + public string Name => Strings.Action_ClearCellOutput; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Clears the output of the selected cell."; + public string? Description => Strings.Action_ClearCellOutput_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; public string ActionId => "verso.action.clear-cell-output"; - public string DisplayName => "Clear Output"; + public string DisplayName => Strings.Action_ClearCellOutput_Label; public string? Icon => ""; public ToolbarPlacement Placement => ToolbarPlacement.CellToolbar; public int Order => 31; diff --git a/src/Verso/Extensions/ToolbarActions/ClearOutputsAction.cs b/src/Verso/Extensions/ToolbarActions/ClearOutputsAction.cs index 9118e4c7..e32ef37d 100644 --- a/src/Verso/Extensions/ToolbarActions/ClearOutputsAction.cs +++ b/src/Verso/Extensions/ToolbarActions/ClearOutputsAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -11,10 +12,10 @@ public sealed class ClearOutputsAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.clear-outputs"; - public string Name => "Clear Outputs"; + public string Name => Strings.Action_ClearOutputs; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Clears all cell outputs in the notebook."; + public string? Description => Strings.Action_ClearOutputs_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -22,7 +23,7 @@ public sealed class ClearOutputsAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.clear-outputs"; - public string DisplayName => "Clear Outputs"; + public string DisplayName => Strings.Action_ClearOutputs; public string? Icon => ""; public bool IconOnly => true; public ToolbarPlacement Placement => ToolbarPlacement.MainToolbar; diff --git a/src/Verso/Extensions/ToolbarActions/ExportHtmlAction.cs b/src/Verso/Extensions/ToolbarActions/ExportHtmlAction.cs index 2623fd1e..9cd14c86 100644 --- a/src/Verso/Extensions/ToolbarActions/ExportHtmlAction.cs +++ b/src/Verso/Extensions/ToolbarActions/ExportHtmlAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; using Verso.Export; namespace Verso.Extensions.ToolbarActions; @@ -12,10 +13,10 @@ public sealed class ExportHtmlAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.export-html"; - public string Name => "Export HTML"; + public string Name => Strings.Action_ExportHtml; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Exports the notebook as a self-contained HTML document."; + public string? Description => Strings.Action_ExportHtml_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -23,6 +24,7 @@ public sealed class ExportHtmlAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.export-html"; + // The export menu names formats, and a format name reads the same in every language. public string DisplayName => "HTML"; public string? Icon => null; public ToolbarPlacement Placement => ToolbarPlacement.ExportMenu; diff --git a/src/Verso/Extensions/ToolbarActions/ExportMarkdownAction.cs b/src/Verso/Extensions/ToolbarActions/ExportMarkdownAction.cs index a519c034..b7563c86 100644 --- a/src/Verso/Extensions/ToolbarActions/ExportMarkdownAction.cs +++ b/src/Verso/Extensions/ToolbarActions/ExportMarkdownAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; using Verso.Export; namespace Verso.Extensions.ToolbarActions; @@ -12,10 +13,10 @@ public sealed class ExportMarkdownAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.export-markdown"; - public string Name => "Export Markdown"; + public string Name => Strings.Action_ExportMarkdown; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Exports the notebook as a Markdown document."; + public string? Description => Strings.Action_ExportMarkdown_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -23,6 +24,7 @@ public sealed class ExportMarkdownAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.export-markdown"; + // The export menu names formats, and a format name reads the same in every language. public string DisplayName => "Markdown"; public string? Icon => null; public ToolbarPlacement Placement => ToolbarPlacement.ExportMenu; diff --git a/src/Verso/Extensions/ToolbarActions/ExportVersoAction.cs b/src/Verso/Extensions/ToolbarActions/ExportVersoAction.cs index 3a3ae30c..c4030900 100644 --- a/src/Verso/Extensions/ToolbarActions/ExportVersoAction.cs +++ b/src/Verso/Extensions/ToolbarActions/ExportVersoAction.cs @@ -1,5 +1,6 @@ using System.Text; using Verso.Abstractions; +using Verso.Resources; using Verso.Serializers; namespace Verso.Extensions.ToolbarActions; @@ -16,10 +17,10 @@ public sealed class ExportVersoAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.export-verso"; - public string Name => "Export Verso"; + public string Name => Strings.Action_ExportVerso; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Exports a Markdown-backed notebook as a native .verso file."; + public string? Description => Strings.Action_ExportVerso_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -27,6 +28,7 @@ public sealed class ExportVersoAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.export-verso"; + // The export menu names formats, and this one is the product's own. public string DisplayName => "Verso"; public string? Icon => null; public ToolbarPlacement Placement => ToolbarPlacement.ExportMenu; diff --git a/src/Verso/Extensions/ToolbarActions/RestartKernelAction.cs b/src/Verso/Extensions/ToolbarActions/RestartKernelAction.cs index 6fc6cbed..321afc43 100644 --- a/src/Verso/Extensions/ToolbarActions/RestartKernelAction.cs +++ b/src/Verso/Extensions/ToolbarActions/RestartKernelAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -11,10 +12,10 @@ public sealed class RestartKernelAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.restart-kernel"; - public string Name => "Restart Kernel"; + public string Name => Strings.Action_RestartKernel; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Restarts the active language kernel."; + public string? Description => Strings.Action_RestartKernel_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -22,11 +23,10 @@ public sealed class RestartKernelAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.restart-kernel"; - public string DisplayName => "Restart Kernel"; + public string DisplayName => Strings.Action_RestartKernel; public string? Icon => ""; public bool IconOnly => true; - public string? ConfirmationPrompt => - "Restarting the kernel discards all variables and execution state. Restart now?"; + public string? ConfirmationPrompt => Strings.Action_RestartKernel_Confirm; public ToolbarPlacement Placement => ToolbarPlacement.MainToolbar; public int Order => 40; diff --git a/src/Verso/Extensions/ToolbarActions/RunAllAction.cs b/src/Verso/Extensions/ToolbarActions/RunAllAction.cs index efc8570a..a20115bd 100644 --- a/src/Verso/Extensions/ToolbarActions/RunAllAction.cs +++ b/src/Verso/Extensions/ToolbarActions/RunAllAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -11,10 +12,10 @@ public sealed class RunAllAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.run-all"; - public string Name => "Run All"; + public string Name => Strings.Action_RunAll; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Executes all cells in the notebook."; + public string? Description => Strings.Action_RunAll_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -22,7 +23,7 @@ public sealed class RunAllAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.run-all"; - public string DisplayName => "Run All"; + public string DisplayName => Strings.Action_RunAll; public string? Icon => ""; public bool IsPrimary => true; public ToolbarPlacement Placement => ToolbarPlacement.MainToolbar; diff --git a/src/Verso/Extensions/ToolbarActions/RunCellAction.cs b/src/Verso/Extensions/ToolbarActions/RunCellAction.cs index 48433fd2..f33bcf08 100644 --- a/src/Verso/Extensions/ToolbarActions/RunCellAction.cs +++ b/src/Verso/Extensions/ToolbarActions/RunCellAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -11,10 +12,10 @@ public sealed class RunCellAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.run-cell"; - public string Name => "Run Cell"; + public string Name => Strings.Action_RunCell; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Executes the selected cell(s)."; + public string? Description => Strings.Action_RunCell_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -22,7 +23,7 @@ public sealed class RunCellAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.action.run-cell"; - public string DisplayName => "Run Cell"; + public string DisplayName => Strings.Action_RunCell; public string? Icon => null; public ToolbarPlacement Placement => ToolbarPlacement.CellToolbar; public int Order => 20; diff --git a/src/Verso/Extensions/ToolbarActions/SwitchLayoutAction.cs b/src/Verso/Extensions/ToolbarActions/SwitchLayoutAction.cs index 9af97c15..3ff272dd 100644 --- a/src/Verso/Extensions/ToolbarActions/SwitchLayoutAction.cs +++ b/src/Verso/Extensions/ToolbarActions/SwitchLayoutAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -11,10 +12,10 @@ public sealed class SwitchLayoutAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.switch-layout"; - public string Name => "Switch Layout"; + public string Name => Strings.Action_SwitchLayout; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Cycles between available layout engines."; + public string? Description => Strings.Action_SwitchLayout_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -22,7 +23,7 @@ public sealed class SwitchLayoutAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.switchLayout"; - public string DisplayName => "Switch Layout"; + public string DisplayName => Strings.Action_SwitchLayout; public string? Icon => null; public ToolbarPlacement Placement => ToolbarPlacement.MainToolbar; public int Order => 50; diff --git a/src/Verso/Extensions/ToolbarActions/SwitchThemeAction.cs b/src/Verso/Extensions/ToolbarActions/SwitchThemeAction.cs index f13f6ebb..7cb523a1 100644 --- a/src/Verso/Extensions/ToolbarActions/SwitchThemeAction.cs +++ b/src/Verso/Extensions/ToolbarActions/SwitchThemeAction.cs @@ -1,4 +1,5 @@ using Verso.Abstractions; +using Verso.Resources; namespace Verso.Extensions.ToolbarActions; @@ -11,10 +12,10 @@ public sealed class SwitchThemeAction : IToolbarAction // --- IExtension --- public string ExtensionId => "verso.action.switch-theme"; - public string Name => "Switch Theme"; + public string Name => Strings.Action_SwitchTheme; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Cycles between available themes."; + public string? Description => Strings.Action_SwitchTheme_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; @@ -22,7 +23,7 @@ public sealed class SwitchThemeAction : IToolbarAction // --- IToolbarAction --- public string ActionId => "verso.switchTheme"; - public string DisplayName => "Switch Theme"; + public string DisplayName => Strings.Action_SwitchTheme; public string? Icon => null; public ToolbarPlacement Placement => ToolbarPlacement.MainToolbar; public int Order => 55; diff --git a/src/Verso/Localization/VersoCultures.cs b/src/Verso/Localization/VersoCultures.cs new file mode 100644 index 00000000..258103a8 --- /dev/null +++ b/src/Verso/Localization/VersoCultures.cs @@ -0,0 +1,167 @@ +using System.Globalization; + +namespace Verso.Localization; + +/// +/// The languages Verso ships an interface in, and the rules for picking one. +/// +/// +/// Every host answers the same question differently: the CLI has a --language option, the +/// web server has an Accept-Language header, the editor extension has a setting. They all +/// end up here so that the answer is the same wherever it was asked, and so that adding a +/// language is one entry rather than a search through four projects. +/// +public static class VersoCultures +{ + /// The language used when nothing else resolves, and the one every string is written in. + public const string Default = "en"; + + /// + /// Environment variable read when no language was requested explicitly. It exists for + /// containers and CI, where passing an option to every invocation is awkward. + /// + public const string EnvironmentVariable = "VERSO_LANGUAGE"; + + /// + /// A generated stand-in for a real translation. Every string is accented and bracketed, so + /// running the interface in it makes anything still in plain English obvious at a glance, + /// and the padding shows where a longer translation would be clipped. It is deliberately + /// absent from so it never appears in a language picker; ask for it + /// by name. + /// + public const string Pseudo = "qps-Ploc"; + + /// + /// The command-line option every host accepts to name a language. + /// + public const string Option = "--language"; + + /// + /// The languages offered in the interface, in the order a picker should list them. + /// + public static IReadOnlyList Supported { get; } = new[] + { + "en", + "de", + "es", + "ja", + "zh-Hans", + }; + + /// + /// Resolves the language to use from an explicit request, falling back through the + /// environment variable and the operating system to English. + /// + /// + /// A language tag from a command-line option or a setting, or null when none was given. + /// The literal string auto is treated the same as null, because that is what the + /// editor setting stores for "decide for me". + /// + /// A culture that has translations, or the invariant-language English culture. + public static CultureInfo Resolve(string? requested) + { + if (TryMatch(requested, out var explicitly)) + return explicitly; + + if (TryMatch(Environment.GetEnvironmentVariable(EnvironmentVariable), out var fromEnvironment)) + return fromEnvironment; + + if (TryMatch(CultureInfo.CurrentUICulture.Name, out var fromSystem)) + return fromSystem; + + return new CultureInfo(Default); + } + + /// + /// Matches a language tag against the shipped set, narrowing a regional tag to its language + /// where there is no regional translation. + /// + /// + /// de-AT resolves to de, and zh-CN reaches zh-Hans through its + /// parent chain, which is why this walks parents rather than comparing prefixes: the tag for + /// simplified Chinese shares no prefix with the tag a browser is likely to send. + /// + /// A language tag, or null. + /// The matched culture, or null when there is no match. + /// true when the tag maps onto a shipped language. + public static bool TryMatch(string? tag, out CultureInfo culture) + { + culture = null!; + + if (string.IsNullOrWhiteSpace(tag) || tag.Equals("auto", StringComparison.OrdinalIgnoreCase)) + return false; + + CultureInfo candidate; + try + { + candidate = new CultureInfo(tag.Trim()); + } + catch (CultureNotFoundException) + { + return false; + } + + // The pseudo-locale is a real translation as far as the resource system is concerned, + // it is just not one anybody should be offered. + if (string.Equals(candidate.Name, Pseudo, StringComparison.OrdinalIgnoreCase)) + { + culture = candidate; + return true; + } + + for (var walk = candidate; !string.IsNullOrEmpty(walk.Name); walk = walk.Parent) + { + foreach (var supported in Supported) + { + if (string.Equals(walk.Name, supported, StringComparison.OrdinalIgnoreCase)) + { + culture = new CultureInfo(supported); + return true; + } + } + } + + return false; + } + + /// + /// Reads the language named by out of raw process arguments. + /// + /// + /// A scan rather than a parse, because the callers that need this need it before parsing is + /// possible: command help is written while the command tree is built, and the notebook host + /// has no parser at all. Both --language de and --language=de are accepted, and + /// anything else is left alone for a real parser to report in the ordinary way. + /// + /// The process arguments. + /// The requested language tag, or null when the option is absent. + public static string? FromArguments(IReadOnlyList args) + { + for (var i = 0; i < args.Count; i++) + { + if (args[i].StartsWith(Option + "=", StringComparison.Ordinal)) + return args[i][(Option.Length + 1)..]; + + if (args[i].Equals(Option, StringComparison.Ordinal) && i + 1 < args.Count) + return args[i + 1]; + } + + return null; + } + + /// + /// Applies a language to the interface without touching how numbers and dates are formatted. + /// + /// + /// The two are deliberately separate. A notebook's own results are data, and this process + /// runs the kernels that produce them, so moving the formatting culture would change a cell's + /// output because somebody picked a menu language. Hosts that run kernels elsewhere, such as + /// the editor extension, can afford to move both and do. + /// + /// The language to display the interface in. + public static void ApplyUiCulture(CultureInfo culture) + { + CultureInfo.DefaultThreadCurrentUICulture = culture; + CultureInfo.CurrentUICulture = culture; + } +} diff --git a/src/Verso/Resources/Strings.qps-Ploc.resx b/src/Verso/Resources/Strings.qps-Ploc.resx new file mode 100644 index 00000000..9c8e8a58 --- /dev/null +++ b/src/Verso/Resources/Strings.qps-Ploc.resx @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + [!!Çléàr Çéll Òùtpùt···!!] + + + [!!Çléàrš thé òùtpùt òf thé šéléçtéd çéll.···!!] + + + [!!Çléàr Òùtpùt···!!] + + + [!!Çléàr Òùtpùtš···!!] + + + [!!Çléàrš àll çéll òùtpùtš ïñ thé ñòtébòòk.···!!] + + + [!!Éxpòrt HTML···!!] + + + [!!Éxpòrtš thé ñòtébòòk àš à šélf-çòñtàïñéd HTML dòçùméñt.···!!] + + + [!!Éxpòrt Màrkdòwñ···!!] + + + [!!Éxpòrtš thé ñòtébòòk àš à Màrkdòwñ dòçùméñt.···!!] + + + [!!Éxpòrt Véršò···!!] + + + [!!Éxpòrtš à Màrkdòwñ-bàçkéd ñòtébòòk àš à ñàtïvé .véršò fïlé.···!!] + + + [!!Réštàrt Kérñél···!!] + + + [!!Réštàrtïñg thé kérñél dïšçàrdš àll vàrïàbléš àñd éxéçùtïòñ štàté. Réštàrt ñòw?···!!] + + + [!!Réštàrtš thé àçtïvé làñgùàgé kérñél.···!!] + + + [!!Rùñ Àll···!!] + + + [!!Éxéçùtéš àll çéllš ïñ thé ñòtébòòk.···!!] + + + [!!Rùñ Çéll···!!] + + + [!!Éxéçùtéš thé šéléçtéd çéllš.···!!] + + + [!!Šwïtçh Làÿòùt···!!] + + + [!!Çÿçléš bétwééñ àvàïlàblé làÿòùt éñgïñéš.···!!] + + + [!!Šwïtçh Thémé···!!] + + + [!!Çÿçléš bétwééñ àvàïlàblé théméš.···!!] + + + [!!Çòdé···!!] + + + [!!HTML Çéll Tÿpé···!!] + + + [!!HTML çéll tÿpé fòr àùthòrïñg ràw HTML wïth @vàrïàblé šùbštïtùtïòñ.···!!] + + + [!!Màrkdòwñ Çéll Tÿpé···!!] + + + [!!Màrkdòwñ pròšé çéllš réñdéréd tò HTML wïth Màrkdïg.···!!] + + + [!!Mérmàïd Çéll Tÿpé···!!] + + + [!!Mérmàïd çéll tÿpé fòr çréàtïñg dïàgràmš wïth mérmàïd.jš šÿñtàx.···!!] + + + [!!Pàràmétérš Çéll Tÿpé···!!] + + + [!!Dïšplàÿš àñd màñàgéš ñòtébòòk pàràmétér défïñïtïòñš àš àñ ïñtéràçtïvé fòrm.···!!] + + + [!!Pàràmétérš···!!] + + + [!!HTML Kérñél···!!] + + + [!!Éxéçùtéš HTML çéllš wïth @vàrïàblé šùbštïtùtïòñ.···!!] + + + [!!Mérmàïd Kérñél···!!] + + + [!!Éxéçùtéš Mérmàïd dïàgràm çéllš wïth @vàrïàblé šùbštïtùtïòñ.···!!] + + + [!!{0} Çéll···!!] + + + [!!Dàšhbòàrd Làÿòùt···!!] + + + [!!Grïd-bàšéd dàšhbòàrd làÿòùt šhòwïñg òùtpùt-òñlÿ çéllš.···!!] + + + [!!Dàšhbòàrd···!!] + + + [!!Dràg tò mòvé···!!] + + + [!!Àdd ÿòùr fïršt çéll bélòw.···!!] + + + [!!Thïš ñòtébòòk ïš émptÿ···!!] + + + [!!Ïñšért à {0} çéll héré···!!] + + + [!!Ñòtébòòk Làÿòùt···!!] + + + [!!Lïñéàr tòp-tò-bòttòm ñòtébòòk làÿòùt wïth lïvé, édïtàblé çéllš ïñ élévàtéd çàrdš.···!!] + + + [!!Ñòtébòòk···!!] + + + [!!Préšéñtàtïòñ Làÿòùt···!!] + + + [!!Òùtpùt-òñlÿ préšéñtàtïòñ làÿòùt fòr çòñšùmïñg ïñtéràçtïvé ñòtébòòkš.···!!] + + + [!!Préšéñtàtïòñ···!!] + + + [!!Rùñ···!!] + + + [!!Àdd Pàràmétér···!!] + + + [!!Pàràmétérš àpplïéd šùççéššfùllÿ.···!!] + + + [!!Çàñçél···!!] + + + [!!Défàùlt···!!] + + + [!!Déšçrïptïòñ···!!] + + + [!!Ñàmé···!!] + + + [!!Réqùïréd···!!] + + + [!!Tÿpé···!!] + + + [!!Àdd···!!] + + + [!!défàùlt vàlùé···!!] + + + [!!déšçrïptïòñ···!!] + + + [!!Ïñvàlïd vàlùé.···!!] + + + [!!ñàmé···!!] + + + [!!Ñò pàràmétérš défïñéd.···!!] + + + [!!Pàràmétér '{0}' ñòt fòùñd.···!!] + + + [!!Rémòvé pàràmétér···!!] + + + [!!réqùïréd···!!] + + + [!!Pàràmétérš···!!] + + + [!!Çòllàpšé ïñpùt···!!] + + + [!!Défàùlt: {0}···!!] + + + [!!Dïšplàÿ···!!] + + + [!!Ïñpùt prévïéw lïñéš···!!] + + + [!!Òùtpùt···!!] + + + [!!Fùll···!!] + + + [!!Hïddéñ···!!] + + + [!!Prévïéw···!!] + + + [!!Òùtpùt prévïéw lïñéš···!!] + + + [!!Prévïéw štÿlé···!!] + + + [!!Lïñéš···!!] + + + [!!Vïšïbïlïtÿ···!!] + + + [!!Çéll Dïšplàÿ Pròpértïéš···!!] + + + [!!Pròvïdéš pér-çéll ïñpùt àñd òùtpùt dïšplàÿ šéttïñgš.···!!] + + + [!!Çéll Vïšïbïlïtÿ Pròpértïéš···!!] + + + [!!Pròvïdéš pér-làÿòùt çéll vïšïbïlïtÿ òvérrïdéš ïñ thé pròpértïéš pàñél.···!!] + + + [!!Fàllbàçk Réñdérér···!!] + + + [!!Fàllbàçk···!!] + + + [!!HTML Réñdérér···!!] + + + [!!Réñdérš HTML çéllš wïth ïñpùt çòllàpšé òñ éxéçùté.···!!] + + + [!!Màrkdòwñ Réñdérér···!!] + + + [!!Réñdérš Màrkdòwñ çéllš ùšïñg Màrkdïg.···!!] + + + [!!Mérmàïd Réñdérér···!!] + + + [!!Réñdérš Mérmàïd dïàgràm çéllš wïth ïñpùt çòllàpšé òñ éxéçùté.···!!] + + + [!!Pàràmétérš Réñdérér···!!] + + + [!!Réñdérš pàràmétér défïñïtïòñš àš àñ ïñtéràçtïvé fòrm wïth tÿpé-àwàré ïñpùtš.···!!] + + + [!!Véršò Dàrk···!!] + + + [!!Défàùlt dàrk thémé fòr Véršò ñòtébòòkš.···!!] + + + [!!Véršò Hïgh Çòñtràšt···!!] + + + [!!Hïgh-çòñtràšt àççéššïbïlïtÿ thémé wïth WÇÀG 2.1 ÀÀ çòmplïàñt çòlòr tòkéñš.···!!] + + + [!!Véršò Lïght···!!] + + + [!!Défàùlt lïght thémé fòr Véršò ñòtébòòkš.···!!] + + + [!!Çòllàpšéd···!!] + + + [!!Hïddéñ···!!] + + + [!!Òùtpùt Òñlÿ···!!] + + + [!!Vïšïblé···!!] + + \ No newline at end of file diff --git a/src/Verso/Resources/Strings.resx b/src/Verso/Resources/Strings.resx new file mode 100644 index 00000000..731fa1a1 --- /dev/null +++ b/src/Verso/Resources/Strings.resx @@ -0,0 +1,481 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Clear Cell Output + Name of the built-in extension that clears one cell's output, as listed in the Extensions panel. + + + Clears the output of the selected cell. + Second line of the Clear Output entry in the Extensions panel. + + + Clear Output + Cell toolbar button. Shorter than the extension's name because it sits on a cell, where the word 'cell' is already implied. + + + Clear Outputs + Toolbar button, and the name of the same built-in extension in the Extensions panel. + + + Clears all cell outputs in the notebook. + Second line of the Clear Outputs entry in the Extensions panel. + + + Export HTML + Name of the built-in HTML export, as listed in the Extensions panel. Its menu entry reads only 'HTML' and is not translated. + + + Exports the notebook as a self-contained HTML document. + Second line of the Export HTML entry in the Extensions panel. + + + Export Markdown + Name of the built-in Markdown export, as listed in the Extensions panel. Its menu entry reads only 'Markdown' and is not translated. + + + Exports the notebook as a Markdown document. + Second line of the Export Markdown entry in the Extensions panel. + + + Export Verso + Name of the built-in .verso export, as listed in the Extensions panel. Verso is a product name. + + + Exports a Markdown-backed notebook as a native .verso file. + Second line of the Export Verso entry in the Extensions panel. + + + Restart Kernel + Toolbar button, and the name of the same built-in extension in the Extensions panel. + + + Restarting the kernel discards all variables and execution state. Restart now? + Confirmation dialog shown before the kernel restarts, because the effect cannot be undone. + + + Restarts the active language kernel. + Second line of the Restart Kernel entry in the Extensions panel. + + + Run All + The toolbar's primary button, and the name of the same built-in extension in the Extensions panel. Keep it short: it sits next to the kernel status. + + + Executes all cells in the notebook. + Second line of the Run All entry in the Extensions panel. + + + Run Cell + Cell toolbar button, and the name of the same built-in extension in the Extensions panel. + + + Executes the selected cells. + Second line of the Run Cell entry in the Extensions panel. + + + Switch Layout + Toolbar button, and the name of the same built-in extension in the Extensions panel. + + + Cycles between available layout engines. + Second line of the Switch Layout entry in the Extensions panel. + + + Switch Theme + Toolbar button, and the name of the same built-in extension in the Extensions panel. + + + Cycles between available themes. + Second line of the Switch Theme entry in the Extensions panel. + + + Code + The kind of cell that holds code. Offered wherever a new cell can be added. + + + HTML Cell Type + Name of the built-in HTML cell type, as listed in the Extensions panel. Its entry in the new-cell list reads only 'HTML' and is not translated. + + + HTML cell type for authoring raw HTML with @variable substitution. + Second line of the HTML cell type entry in the Extensions panel. '@variable' is typed by the reader and stays as written. + + + Markdown Cell Type + Name of the built-in Markdown cell type, as listed in the Extensions panel. + + + Markdown prose cells rendered to HTML with Markdig. + Second line of the Markdown cell type entry in the Extensions panel. Markdig is a library name. + + + Mermaid Cell Type + Name of the built-in Mermaid cell type, as listed in the Extensions panel. + + + Mermaid cell type for creating diagrams with mermaid.js syntax. + Second line of the Mermaid cell type entry in the Extensions panel. mermaid.js is a library name. + + + Parameters Cell Type + Name of the built-in parameters cell type, as listed in the Extensions panel. + + + Displays and manages notebook parameter definitions as an interactive form. + Second line of the parameters cell type entry in the Extensions panel. + + + Parameters + The parameters cell as offered in the new-cell list. + + + HTML Kernel + Name of the built-in HTML kernel, as listed in the Extensions panel. + + + Executes HTML cells with @variable substitution. + Second line of the HTML kernel entry in the Extensions panel. '@variable' is typed by the reader and stays as written. + + + Mermaid Kernel + Name of the built-in Mermaid kernel, as listed in the Extensions panel. + + + Executes Mermaid diagram cells with @variable substitution. + Second line of the Mermaid kernel entry in the Extensions panel. '@variable' is typed by the reader and stays as written. + + + {0} Cell + Button under the last cell that appends a new one. {0} is the kind of cell, such as Code or Markdown. + + + Dashboard Layout + Name of the built-in dashboard layout, as listed in the Extensions panel. + + + Grid-based dashboard layout showing output-only cells. + Second line of the dashboard layout entry in the Extensions panel. + + + Dashboard + The dashboard layout as offered in the layout picker. + + + Drag to move + Tooltip on the handle that repositions a cell on the dashboard. + + + Add your first cell below. + Second line of the empty-notebook message, pointing at the row of buttons under it. + + + This notebook is empty + Shown in place of the cells when a notebook has none yet. + + + Insert a {0} cell here + Tooltip on a button that adds a cell between two others. {0} is the kind of cell, such as Code or Markdown. + + + Notebook Layout + Name of the built-in notebook layout, as listed in the Extensions panel. + + + Linear top-to-bottom notebook layout with live, editable cells in elevated cards. + Second line of the notebook layout entry in the Extensions panel. + + + Notebook + The notebook layout as offered in the layout picker. + + + Presentation Layout + Name of the built-in presentation layout, as listed in the Extensions panel. + + + Output-only presentation layout for consuming interactive notebooks. + Second line of the presentation layout entry in the Extensions panel. + + + Presentation + The presentation layout as offered in the layout picker. + + + Run + Tooltip on the button that runs one cell on the dashboard. Keep it to a word: the button holds only an icon. + + + Add Parameter + Button that opens the row for entering a new parameter. + + + Parameters applied successfully. + Shown after the values in a parameters cell have been handed to the kernel. + + + Cancel + Tooltip on the cross that abandons the new parameter. + + + Default + Column heading in the parameters table. + + + Description + Column heading in the parameters table. + + + Name + Column heading in the parameters table. + + + Required + Column heading in the parameters table. + + + Type + Column heading in the parameters table. + + + Add + Tooltip on the tick that saves the new parameter. + + + default value + Grey prompt inside the empty default-value box. Lower case, matching the other prompts in the row. + + + description + Grey prompt inside the empty description box. Lower case, matching the other prompts in the row. + + + Invalid value. + Shown beside a parameter whose value does not fit its type. + + + name + Grey prompt inside the empty name box. Lower case, matching the other prompts in the row. + + + No parameters defined. + Shown in a parameters cell that has nothing in it yet. + + + Parameter '{0}' not found. + Shown when a parameter has been removed under the reader's feet. {0} is the parameter's name. + + + Remove parameter + Tooltip on the cross that deletes a parameter. + + + required + Checkbox beside a new parameter, marking it as one the notebook cannot run without. Lower case, matching the prompts in the same row. + + + Parameters + Heading of the parameters cell. + + + Collapse input + Switch that hides a cell's source and leaves its output showing. + + + Default: {0} + Note under a cell visibility setting saying what it falls back to. {0} is one of the visibility states. + + + Display + Heading over the settings that control how a cell is drawn. + + + Input preview lines + How many lines of source a collapsed cell shows. + + + Output + Label on the control choosing how much of a cell's output is shown. + + + Full + Output setting: show all of it. + + + Hidden + Output setting: show none of it. + + + Preview + Output setting: show the first few lines. + + + Output preview lines + How many lines of output a previewed cell shows. + + + Preview style + Label on the control choosing how a preview is drawn. + + + Lines + Preview style: the first few lines, as written. + + + Visibility + Heading over the settings that control which layouts a cell appears in. + + + Cell Display Properties + Name of the built-in provider of the Display settings, as listed in the Extensions panel. + + + Provides per-cell input and output display settings. + Second line of the Cell Display Properties entry in the Extensions panel. + + + Cell Visibility Properties + Name of the built-in provider of the Visibility settings, as listed in the Extensions panel. + + + Provides per-layout cell visibility overrides in the properties panel. + Second line of the Cell Visibility Properties entry in the Extensions panel. + + + Fallback Renderer + Name of the renderer used when a cell type has no renderer of its own, as listed in the Extensions panel. + + + Fallback + Short name of the fallback renderer. + + + HTML Renderer + Name of the built-in HTML renderer, as listed in the Extensions panel. + + + Renders HTML cells with input collapse on execute. + Second line of the HTML renderer entry in the Extensions panel. + + + Markdown Renderer + Name of the built-in Markdown renderer, as listed in the Extensions panel. + + + Renders Markdown cells using Markdig. + Second line of the Markdown renderer entry in the Extensions panel. Markdig is a library name. + + + Mermaid Renderer + Name of the built-in Mermaid renderer, as listed in the Extensions panel. + + + Renders Mermaid diagram cells with input collapse on execute. + Second line of the Mermaid renderer entry in the Extensions panel. + + + Parameters Renderer + Name of the built-in parameters renderer, as listed in the Extensions panel. + + + Renders parameter definitions as an interactive form with type-aware inputs. + Second line of the parameters renderer entry in the Extensions panel. + + + Verso Dark + The built-in dark theme. Verso is a product name and stays as written; the word describing the theme is translated. + + + Default dark theme for Verso notebooks. + Second line of the dark theme entry in the Extensions panel. + + + Verso High Contrast + The built-in high-contrast theme. Verso is a product name and stays as written; the words describing the theme are translated. + + + High-contrast accessibility theme with WCAG 2.1 AA compliant color tokens. + Second line of the high-contrast theme entry in the Extensions panel. WCAG 2.1 AA is the name of a standard. + + + Verso Light + The built-in light theme. Verso is a product name and stays as written; the word describing the theme is translated. + + + Default light theme for Verso notebooks. + Second line of the light theme entry in the Extensions panel. + + + Collapsed + Cell visibility state: the cell is drawn folded shut. + + + Hidden + Cell visibility state: the cell is not drawn at all. + + + Output Only + Cell visibility state: the output is drawn and the source is not. + + + Visible + Cell visibility state: the whole cell is drawn. + + \ No newline at end of file diff --git a/src/Verso/Verso.csproj b/src/Verso/Verso.csproj index e8fa4e85..0dd17dda 100644 --- a/src/Verso/Verso.csproj +++ b/src/Verso/Verso.csproj @@ -17,8 +17,31 @@ + + + + MSBuild:Compile + $(IntermediateOutputPath)Strings.Designer.cs + CSharp + Verso.Resources + Strings + + + + + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 00000000..d16948b6 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/tests/EnglishTestCulture.cs b/tests/EnglishTestCulture.cs new file mode 100644 index 00000000..63ad1de1 --- /dev/null +++ b/tests/EnglishTestCulture.cs @@ -0,0 +1,37 @@ +using System.Globalization; +// Written out rather than relied on: most test projects import this namespace globally, but +// not all of them, and this file is compiled into every one. +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Verso.Tests.Shared; + +/// +/// Pins every test run to English. +/// +/// +/// Assertions are written against the English wording of the interface, and a test process +/// otherwise takes its language from the machine it runs on. Without this, the suite passes in +/// London and fails in Berlin, which is a difference nobody would think to look for. +/// +/// Only the interface language is pinned. Formatting is left alone, so a test that depends on +/// how a number or a date is written still fails on a machine where that differs, which is a +/// real difference worth catching rather than hiding. +/// +/// +/// This file is compiled into every test assembly by tests/Directory.Build.props. Each +/// assembly needs its own copy because the hook runs once per assembly, not once per run. +/// +/// +[TestClass] +public static class EnglishTestCulture +{ + // Fully qualified, because bUnit brings a TestContext of its own into scope in the + // component test assembly and the two names collide. + [AssemblyInitialize] + public static void Pin(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext context) + { + _ = context; + CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.GetCultureInfo("en"); + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en"); + } +} diff --git a/tests/Verso.Blazor.Shared.Tests/DiffSourcesTests.cs b/tests/Verso.Blazor.Shared.Tests/DiffSourcesTests.cs new file mode 100644 index 00000000..af7bedf2 --- /dev/null +++ b/tests/Verso.Blazor.Shared.Tests/DiffSourcesTests.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using Verso.Blazor.Shared.Resources; + +namespace Verso.Blazor.Shared.Tests; + +/// +/// The baselines a notebook can be compared against: what they are called, and what stays +/// the same whatever they are called. +/// +/// +/// Both hosts read these names, and in the embedded shell the editor picks the baseline +/// while the notebook names it. The editor's menus follow the language its workbench is +/// set to and the notebook follows the language its interface is set to, so what crosses +/// between them has to be the id, never the name. +/// +[TestClass] +public sealed class DiffSourcesTests +{ + private static readonly CultureInfo Pseudo = CultureInfo.GetCultureInfo("qps-Ploc"); + + private static void InPseudoLocale(Action assert) + { + var original = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = Pseudo; + try + { + assert(); + } + finally + { + CultureInfo.CurrentUICulture = original; + } + } + + [TestMethod] + public void NameOf_KnowsTheFourBuiltInSources() + { + Assert.AreEqual(UI.Compare_SourceLastSaved, DiffSources.NameOf(DiffSources.LastSaved)); + Assert.AreEqual(UI.Compare_SourceGitHead, DiffSources.NameOf(DiffSources.GitHead)); + Assert.AreEqual(UI.Compare_SourceGitRef, DiffSources.NameOf(DiffSources.GitRef)); + Assert.AreEqual(UI.Compare_SourceChooseFile, DiffSources.NameOf(DiffSources.File)); + } + + [TestMethod] + public void NameOf_LeavesAnythingElseUnnamed() + { + // A host may offer a baseline of its own. Nothing here knows what to call it, and + // returning null says so rather than inventing a name for it. + Assert.IsNull(DiffSources.NameOf("someOtherBaseline")); + Assert.IsNull(DiffSources.NameOf(null)); + } + + [TestMethod] + public void NameOf_FollowsTheCurrentLanguage() + { + var english = DiffSources.NameOf(DiffSources.LastSaved); + + InPseudoLocale(() => Assert.AreNotEqual( + english, DiffSources.NameOf(DiffSources.LastSaved), + "The name was resolved once and kept, so one reader's language would reach them all.")); + + Assert.AreEqual(english, DiffSources.NameOf(DiffSources.LastSaved), + "The name did not come back after the language did."); + } + + [TestMethod] + public void Ids_AreTheSameInEveryLanguage() + { + InPseudoLocale(() => + { + Assert.AreEqual("lastSaved", DiffSources.LastSaved); + Assert.IsNotNull(DiffSources.NameOf(DiffSources.GitHead)); + }); + } + + [TestMethod] + public void ResolvedName_CarriesTheRefAndTheFileNameThrough() + { + // A branch name and a file name are the same in every language, which is why they + // travel from wherever the baseline was picked instead of being chosen here. + StringAssert.Contains( + DiffSources.ResolvedName(DiffSources.GitRef, "release/2.0"), "release/2.0"); + Assert.AreEqual( + "quarterly-report.verso", + DiffSources.ResolvedName(DiffSources.File, "quarterly-report.verso")); + } + + [TestMethod] + public void ResolvedName_FallsBackToTheWordBaseline() + { + // Said of a comparison that ran against something this build cannot name, which is + // still true of whatever it turned out to be. + Assert.AreEqual(UI.Compare_Baseline, DiffSources.ResolvedName("someOtherBaseline", null)); + Assert.AreEqual(UI.Compare_Baseline, DiffSources.ResolvedName(DiffSources.File, " ")); + } + + [TestMethod] + public void UnavailableReason_ExplainsTheTwoItKnows() + { + Assert.AreEqual(UI.Compare_NotSavedYet, DiffSources.UnavailableReason(DiffSources.LastSaved)); + Assert.AreEqual(UI.Compare_NotInGitRepo, DiffSources.UnavailableReason(DiffSources.GitHead)); + Assert.AreEqual(UI.Compare_NotInGitRepo, DiffSources.UnavailableReason(DiffSources.GitRef)); + + // Choosing a file always works, so there is nothing to explain. + Assert.IsNull(DiffSources.UnavailableReason(DiffSources.File)); + Assert.IsNull(DiffSources.UnavailableReason("someOtherBaseline")); + } +} diff --git a/tests/Verso.Blazor.Shared.Tests/ExtensionPanelTests.cs b/tests/Verso.Blazor.Shared.Tests/ExtensionPanelTests.cs index ce2b8fc9..4efcce80 100644 --- a/tests/Verso.Blazor.Shared.Tests/ExtensionPanelTests.cs +++ b/tests/Verso.Blazor.Shared.Tests/ExtensionPanelTests.cs @@ -1,3 +1,5 @@ +using Verso.Blazor.Shared.Resources; + namespace Verso.Blazor.Shared.Tests; [TestClass] @@ -35,8 +37,8 @@ public void WithExtensions_ShowsCategoryGroups() .Add(e => e.Service, _service)); // Groups should be visible (collapsed) - Assert.IsTrue(cut.Markup.Contains("Language Kernels")); - Assert.IsTrue(cut.Markup.Contains("Themes")); + Assert.IsTrue(cut.Markup.Contains(UI.Capability_LanguageKernel_Plural)); + Assert.IsTrue(cut.Markup.Contains(UI.Capability_Theme_Plural)); } [TestMethod] @@ -244,7 +246,7 @@ public void InstalledPackage_ThatRegisteredNothing_IsFlagged() var cut = RenderComponent(p => p .Add(e => e.Service, _service)); - Assert.IsTrue(cut.Markup.Contains("Adds nothing")); + Assert.IsTrue(cut.Markup.Contains(UI.Marketplace_AddsNothing)); } [TestMethod] @@ -260,7 +262,7 @@ public void InstalledPackage_WithNothingRecorded_IsNotFlagged() var cut = RenderComponent(p => p .Add(e => e.Service, _service)); - Assert.IsFalse(cut.Markup.Contains("Adds nothing")); + Assert.IsFalse(cut.Markup.Contains(UI.Marketplace_AddsNothing)); } [TestMethod] @@ -380,7 +382,7 @@ public void SearchResult_NotInstalled_ClaimsNothingAboutWhatItAdds() var cut = RenderSearch("json"); - Assert.IsFalse(cut.Markup.Contains("Adds nothing")); + Assert.IsFalse(cut.Markup.Contains(UI.Marketplace_AddsNothing)); Assert.AreEqual(0, cut.FindAll(".verso-marketplace-add").Count); } @@ -394,7 +396,7 @@ public void ContextChips_StateScopeAndSource() var chips = cut.FindAll(".verso-marketplace-context-chip").Select(e => e.TextContent.Trim()).ToList(); Assert.AreEqual(2, chips.Count); - Assert.IsTrue(chips[0].Contains("this notebook")); + Assert.IsTrue(chips[0].Contains(UI.Marketplace_ThisNotebook)); Assert.IsTrue(chips[1].Contains("nuget.org")); } diff --git a/tests/Verso.Blazor.Shared.Tests/HostPanelsCultureTests.cs b/tests/Verso.Blazor.Shared.Tests/HostPanelsCultureTests.cs new file mode 100644 index 00000000..2befc54d --- /dev/null +++ b/tests/Verso.Blazor.Shared.Tests/HostPanelsCultureTests.cs @@ -0,0 +1,61 @@ +using System.Globalization; + +namespace Verso.Blazor.Shared.Tests; + +/// +/// The built-in panel list is written in whatever language is current when it is asked for. +/// +/// +/// The list used to be a static readonly field, which is built once, the first time +/// anything touches it. On a server that is whoever opened a notebook first, and every reader +/// after them would have seen the panel rail in that person's language. +/// +[TestClass] +public sealed class HostPanelsCultureTests +{ + private static readonly CultureInfo Pseudo = CultureInfo.GetCultureInfo("qps-Ploc"); + + [TestMethod] + public void All_FollowsTheCurrentLanguage() + { + var english = HostPanels.All.Select(p => p.DisplayName).ToList(); + + var original = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = Pseudo; + List pseudo; + try + { + pseudo = HostPanels.All.Select(p => p.DisplayName).ToList(); + } + finally + { + CultureInfo.CurrentUICulture = original; + } + + CollectionAssert.AreNotEqual(english, pseudo, + "The panel list was built once and kept, so it holds whichever language touched it first."); + CollectionAssert.AreEqual( + english, HostPanels.All.Select(p => p.DisplayName).ToList(), + "The names did not come back after the language did."); + } + + [TestMethod] + public void Ids_AreTheSameInEveryLanguage() + { + var english = HostPanels.All.Select(p => p.PanelId).ToList(); + + var original = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = Pseudo; + try + { + CollectionAssert.AreEqual( + english, HostPanels.All.Select(p => p.PanelId).ToList(), + "Panel ids are stored and compared against, so translating one would break the lookup."); + Assert.IsNotNull(HostPanels.Find(HostPanels.Compare)); + } + finally + { + CultureInfo.CurrentUICulture = original; + } + } +} diff --git a/tests/Verso.Blazor.Shared.Tests/NotebookPageCompareTests.cs b/tests/Verso.Blazor.Shared.Tests/NotebookPageCompareTests.cs index d084fada..2cec3608 100644 --- a/tests/Verso.Blazor.Shared.Tests/NotebookPageCompareTests.cs +++ b/tests/Verso.Blazor.Shared.Tests/NotebookPageCompareTests.cs @@ -106,7 +106,11 @@ public void HostRequestedComparison_OpensThePanel() cut.Render(); Assert.AreEqual("gitHead", service.LastDiffSourceId); - Assert.AreEqual("COMPARE", cut.Find(".verso-panel-title").TextContent); + // The header is drawn in capitals by the stylesheet, so the markup holds the word + // as written rather than a copy the code has already changed the case of. + Assert.AreEqual( + Verso.Blazor.Shared.Resources.UI.Panel_Compare, + cut.Find(".verso-panel-title").TextContent); Assert.AreEqual(0, cut.FindAll(".verso-diff-overlay").Count, "A comparison from outside lands in the panel, not over the notebook."); } diff --git a/tests/Verso.Blazor.Shared.Tests/PanelDisplayNamesTests.cs b/tests/Verso.Blazor.Shared.Tests/PanelDisplayNamesTests.cs index 5191a5d8..066ee11d 100644 --- a/tests/Verso.Blazor.Shared.Tests/PanelDisplayNamesTests.cs +++ b/tests/Verso.Blazor.Shared.Tests/PanelDisplayNamesTests.cs @@ -1,26 +1,38 @@ +using Verso.Blazor.Shared.Resources; + namespace Verso.Blazor.Shared.Tests; [TestClass] public sealed class PanelDisplayNamesTests { [TestMethod] - public void For_Properties_ReturnsCellProperties() + public void For_Properties_SaysWhichProperties() + { + // The toggle reads "Properties"; the header it opens has room to say more. + Assert.AreEqual(UI.Panel_PropertiesHeading, PanelDisplayNames.For("properties")); + Assert.AreNotEqual(UI.Panel_Properties, PanelDisplayNames.For("properties")); + } + + [TestMethod] + public void For_KnownPanels_ReturnsTheirNames() { - Assert.AreEqual("CELL PROPERTIES", PanelDisplayNames.For("properties")); + Assert.AreEqual(UI.Panel_Metadata, PanelDisplayNames.For("metadata")); + Assert.AreEqual(UI.Panel_Extensions, PanelDisplayNames.For("extensions")); + Assert.AreEqual(UI.Panel_Variables, PanelDisplayNames.For("variables")); + Assert.AreEqual(UI.Panel_Settings, PanelDisplayNames.For("settings")); } [TestMethod] - public void For_KnownPanels_ReturnsUppercaseLabels() + public void For_UnknownPanel_FallsBackToTheId() { - Assert.AreEqual("METADATA", PanelDisplayNames.For("metadata")); - Assert.AreEqual("EXTENSIONS", PanelDisplayNames.For("extensions")); - Assert.AreEqual("VARIABLES", PanelDisplayNames.For("variables")); - Assert.AreEqual("SETTINGS", PanelDisplayNames.For("settings")); + Assert.AreEqual("custom-panel", PanelDisplayNames.For("custom-panel")); } [TestMethod] - public void For_UnknownPanel_FallsBackToUppercase() + public void For_LeavesCaseAlone() { - Assert.AreEqual("CUSTOM-PANEL", PanelDisplayNames.For("custom-panel")); + // Headers are drawn in capitals by the stylesheet. Doing it here would apply one + // language's case rules to every language's words. + Assert.AreEqual("Metadata", PanelDisplayNames.For("metadata")); } } diff --git a/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceDiffTests.cs b/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceDiffTests.cs index fc735646..aad7da91 100644 --- a/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceDiffTests.cs +++ b/tests/Verso.Blazor.Shared.Tests/ServerNotebookServiceDiffTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Verso.Abstractions; using Verso.Blazor.Services; +using Verso.Blazor.Shared.Resources; namespace Verso.Blazor.Shared.Tests; @@ -169,7 +170,9 @@ public async Task ComputeDiffAsync_MalformedBaselineFile_ThrowsFriendlyError() var ex = await Assert.ThrowsExceptionAsync( () => service.ComputeDiffAsync("file", badPath)); - StringAssert.Contains(ex.Message, "Could not parse"); + // Against the resource rather than the English, so rewording the message does not + // break the test and translating it does not make the test pass by accident. + StringAssert.Contains(ex.Message, string.Format(UI.Compare_ParseFailed, "broken.verso", "").TrimEnd()); } private static ServerNotebookService NewService() diff --git a/tests/Verso.Tests/Localization/BuiltInExtensionCultureTests.cs b/tests/Verso.Tests/Localization/BuiltInExtensionCultureTests.cs new file mode 100644 index 00000000..cbb37558 --- /dev/null +++ b/tests/Verso.Tests/Localization/BuiltInExtensionCultureTests.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using Verso.Abstractions; +using Verso.Extensions.CellTypes; +using Verso.Extensions.Layouts; +using Verso.Extensions.Themes; +using Verso.Extensions.ToolbarActions; + +namespace Verso.Tests.Localization; + +/// +/// The built-in extensions answer in whatever language is current when they are asked, +/// rather than in whichever one happened to be current the first time. +/// +/// +/// A server draws notebooks for several readers from one process, and each of them may have +/// asked for a different language. Anything that resolves its name once and keeps it would +/// serve the first reader's language to everybody after them, and the failure is invisible in +/// a single-language test run. The pseudo-locale is used here because it exists whether or not +/// a translator has reached these strings yet. +/// +[TestClass] +public sealed class BuiltInExtensionCultureTests +{ + private static readonly CultureInfo Pseudo = CultureInfo.GetCultureInfo("qps-Ploc"); + + private static void InPseudoLocale(Action assert) + { + var original = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = Pseudo; + try + { + assert(); + } + finally + { + CultureInfo.CurrentUICulture = original; + } + } + + [TestMethod] + public void ToolbarAction_FollowsTheCurrentLanguage() + { + var action = new RunAllAction(); + var english = action.DisplayName; + + InPseudoLocale(() => Assert.AreNotEqual( + english, action.DisplayName, + "Run All resolved its label once and kept it, so a second reader would be served the first one's language.")); + + Assert.AreEqual(english, action.DisplayName, + "The label did not come back after the language did."); + } + + [TestMethod] + public void ConfirmationPrompt_FollowsTheCurrentLanguage() + { + var action = new RestartKernelAction(); + var english = action.ConfirmationPrompt; + + InPseudoLocale(() => Assert.AreNotEqual(english, action.ConfirmationPrompt)); + } + + [TestMethod] + public void ExtensionDescription_FollowsTheCurrentLanguage() + { + IExtension extension = new MarkdownCellType(); + var english = extension.Description; + + InPseudoLocale(() => Assert.AreNotEqual(english, extension.Description)); + } + + [TestMethod] + public void LayoutName_FollowsTheCurrentLanguage() + { + var layout = new NotebookLayout(); + var english = layout.DisplayName; + + InPseudoLocale(() => Assert.AreNotEqual(english, layout.DisplayName)); + } + + [TestMethod] + public void ThemeName_FollowsTheCurrentLanguage() + { + // Only the word describing the theme is a translator's to change; the product's name + // stays as written. That is a rule for whoever writes the translation, carried by the + // note on the resource, and there is nothing in the code here to hold it to. + var theme = new VersoDarkTheme(); + var english = theme.DisplayName; + + InPseudoLocale(() => Assert.AreNotEqual(english, theme.DisplayName)); + } + + [TestMethod] + public void ExportMenuEntries_NameFormatsAndStayAsWritten() + { + // These read "HTML" and "Markdown" rather than a sentence, and a format's name is the + // same in every language. Their entries in the Extensions panel are translated; these + // are not, and a translation appearing here would be the mistake. + var html = new ExportHtmlAction(); + var markdown = new ExportMarkdownAction(); + + InPseudoLocale(() => + { + Assert.AreEqual("HTML", html.DisplayName); + Assert.AreEqual("Markdown", markdown.DisplayName); + }); + } +} diff --git a/tests/Verso.Tests/Localization/VersoCulturesTests.cs b/tests/Verso.Tests/Localization/VersoCulturesTests.cs new file mode 100644 index 00000000..36fa6e5d --- /dev/null +++ b/tests/Verso.Tests/Localization/VersoCulturesTests.cs @@ -0,0 +1,165 @@ +using System.Globalization; +using Verso.Localization; + +namespace Verso.Tests.Localization; + +[TestClass] +public class VersoCulturesTests +{ + private CultureInfo _originalUiCulture = null!; + private string? _originalEnvironment; + + [TestInitialize] + public void Setup() + { + _originalUiCulture = CultureInfo.CurrentUICulture; + _originalEnvironment = Environment.GetEnvironmentVariable(VersoCultures.EnvironmentVariable); + Environment.SetEnvironmentVariable(VersoCultures.EnvironmentVariable, null); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable(VersoCultures.EnvironmentVariable, _originalEnvironment); + CultureInfo.CurrentUICulture = _originalUiCulture; + CultureInfo.DefaultThreadCurrentUICulture = null; + } + + [TestMethod] + public void Supported_LeadsWithTheLanguageEverythingIsWrittenIn() + { + Assert.AreEqual(VersoCultures.Default, VersoCultures.Supported[0]); + } + + [TestMethod] + public void Supported_DoesNotOfferThePseudoLocale() + { + CollectionAssert.DoesNotContain(VersoCultures.Supported.ToList(), VersoCultures.Pseudo); + } + + [TestMethod] + public void TryMatch_AcceptsAShippedLanguage() + { + Assert.IsTrue(VersoCultures.TryMatch("de", out var culture)); + Assert.AreEqual("de", culture.Name); + } + + [TestMethod] + public void TryMatch_NarrowsARegionToItsLanguage() + { + Assert.IsTrue(VersoCultures.TryMatch("de-AT", out var culture)); + Assert.AreEqual("de", culture.Name); + } + + [TestMethod] + public void TryMatch_ReachesSimplifiedChineseThroughTheParentChain() + { + // zh-CN shares no prefix with zh-Hans, so a prefix comparison would miss it. This is the + // tag a browser or an editor is most likely to send for simplified Chinese. + Assert.IsTrue(VersoCultures.TryMatch("zh-CN", out var culture)); + Assert.AreEqual("zh-Hans", culture.Name); + } + + [TestMethod] + public void TryMatch_AcceptsThePseudoLocaleByName() + { + Assert.IsTrue(VersoCultures.TryMatch("qps-ploc", out var culture)); + Assert.AreEqual(VersoCultures.Pseudo, culture.Name); + } + + [TestMethod] + public void TryMatch_RejectsALanguageWithNoTranslation() + { + Assert.IsFalse(VersoCultures.TryMatch("fi", out _)); + } + + [TestMethod] + public void TryMatch_RejectsNonsense() + { + Assert.IsFalse(VersoCultures.TryMatch("not-a-language-tag", out _)); + } + + [TestMethod] + public void TryMatch_TreatsAutoAsNoAnswer() + { + Assert.IsFalse(VersoCultures.TryMatch("auto", out _)); + } + + [TestMethod] + public void TryMatch_TreatsNullAndBlankAsNoAnswer() + { + Assert.IsFalse(VersoCultures.TryMatch(null, out _)); + Assert.IsFalse(VersoCultures.TryMatch(" ", out _)); + } + + [TestMethod] + public void Resolve_PrefersTheExplicitRequest() + { + Environment.SetEnvironmentVariable(VersoCultures.EnvironmentVariable, "ja"); + CultureInfo.CurrentUICulture = new CultureInfo("es"); + + Assert.AreEqual("de", VersoCultures.Resolve("de").Name); + } + + [TestMethod] + public void Resolve_FallsBackToTheEnvironmentVariable() + { + Environment.SetEnvironmentVariable(VersoCultures.EnvironmentVariable, "ja"); + CultureInfo.CurrentUICulture = new CultureInfo("es"); + + Assert.AreEqual("ja", VersoCultures.Resolve(null).Name); + } + + [TestMethod] + public void Resolve_FallsBackToTheSystemLanguage() + { + CultureInfo.CurrentUICulture = new CultureInfo("es-MX"); + + Assert.AreEqual("es", VersoCultures.Resolve(null).Name); + } + + [TestMethod] + public void Resolve_FallsBackToEnglishWhenNothingMatches() + { + CultureInfo.CurrentUICulture = new CultureInfo("fi-FI"); + + Assert.AreEqual(VersoCultures.Default, VersoCultures.Resolve("nope").Name); + } + + [TestMethod] + public void FromArguments_ReadsASeparateValue() + { + Assert.AreEqual("de", VersoCultures.FromArguments(new[] { "serve", "--language", "de" })); + } + + [TestMethod] + public void FromArguments_ReadsAnAttachedValue() + { + Assert.AreEqual("zh-Hans", VersoCultures.FromArguments(new[] { "--language=zh-Hans", "run" })); + } + + [TestMethod] + public void FromArguments_IgnoresAnOptionWithNothingAfterIt() + { + Assert.IsNull(VersoCultures.FromArguments(new[] { "run", "--language" })); + } + + [TestMethod] + public void FromArguments_AnswersNothingWhenTheOptionIsAbsent() + { + Assert.IsNull(VersoCultures.FromArguments(new[] { "run", "notebook.verso", "--verbose" })); + } + + [TestMethod] + public void ApplyUiCulture_LeavesNumberFormattingAlone() + { + // A cell's results are data. Picking a menu language must not change how this process + // renders the numbers a kernel produced. + var before = CultureInfo.CurrentCulture.Name; + + VersoCultures.ApplyUiCulture(new CultureInfo("de")); + + Assert.AreEqual("de", CultureInfo.CurrentUICulture.Name); + Assert.AreEqual(before, CultureInfo.CurrentCulture.Name); + } +} diff --git a/vscode/.gitignore b/vscode/.gitignore index fb14b6e8..0d3413f4 100644 --- a/vscode/.gitignore +++ b/vscode/.gitignore @@ -4,3 +4,4 @@ out/ out-test/ /host/ *.vsix +.vscode-test/ diff --git a/vscode/l10n/bundle.l10n.json b/vscode/l10n/bundle.l10n.json new file mode 100644 index 00000000..39e86fef --- /dev/null +++ b/vscode/l10n/bundle.l10n.json @@ -0,0 +1,301 @@ +{ + "Verso: Could not find Verso.Host.dll. Set \"verso.hostPath\" in settings to the path of your built Verso.Host.dll.": "Verso: Could not find Verso.Host.dll. Set \"verso.hostPath\" in settings to the path of your built Verso.Host.dll.", + "Verso: Open a notebook to compare it with a baseline.": "Verso: Open a notebook to compare it with a baseline.", + "unavailable/Said of a baseline that cannot be compared against, for a reason nothing here knows.": { + "message": "unavailable", + "comment": [ + "Said of a baseline that cannot be compared against, for a reason nothing here knows." + ] + }, + "Compare notebook with...": "Compare notebook with...", + "This comparison source is not available.": "This comparison source is not available.", + "Verso host process exited ({0})": "Verso host process exited ({0})", + "Verso: Failed to start host process: {0}": "Verso: Failed to start host process: {0}", + "Verso needs the .NET runtime (version {0} or later) to run notebooks, but a compatible installation was not found.": "Verso needs the .NET runtime (version {0} or later) to run notebooks, but a compatible installation was not found.", + "Install .NET Runtime/A button. .NET is a product name and stays as written.": { + "message": "Install .NET Runtime", + "comment": [ + "A button. .NET is a product name and stays as written." + ] + }, + "Setup Help/A button. It opens the page describing how to set Verso up.": { + "message": "Setup Help", + "comment": [ + "A button. It opens the page describing how to set Verso up." + ] + }, + "Verso: the .NET runtime is installed. Reopen the notebook to continue.": "Verso: the .NET runtime is installed. Reopen the notebook to continue.", + "Verso: installing the .NET runtime...": "Verso: installing the .NET runtime...", + "This notebook is not inside a git repository.": "This notebook is not inside a git repository.", + "(unnamed)/Stands in for a branch or tag that has no name to show.": { + "message": "(unnamed)", + "comment": [ + "Stands in for a branch or tag that has no name to show." + ] + }, + "branch/Says what kind of thing is listed, shown beside its name. A line of work in version control.": { + "message": "branch", + "comment": [ + "Says what kind of thing is listed, shown beside its name. A line of work in version control." + ] + }, + "remote branch/A branch that lives on the server rather than on this machine.": { + "message": "remote branch", + "comment": [ + "A branch that lives on the server rather than on this machine." + ] + }, + "tag/A name pinned to one point in a project's history.": { + "message": "tag", + "comment": [ + "A name pinned to one point in a project's history." + ] + }, + "'{0}' is not tracked at '{1}'. Commit the file first, or pick a different ref.": "'{0}' is not tracked at '{1}'. Commit the file first, or pick a different ref.", + "'{0}' is not a known branch, tag, or commit.": "'{0}' is not a known branch, tag, or commit.", + "git could not read '{0}' at '{1}': {2}": "git could not read '{0}' at '{1}': {2}", + "Select a notebook for @verso/@verso is typed to address the assistant and stays as written.": { + "message": "Select a notebook for @verso", + "comment": [ + "@verso is typed to address the assistant and stays as written." + ] + }, + "{0} line/Used when {0} is 1. Paired with the entry below.": { + "message": "{0} line", + "comment": [ + "Used when {0} is 1. Paired with the entry below." + ] + }, + "{0} lines/Used for every count other than 1. A language with one form for both translates this the same as the entry above.": { + "message": "{0} lines", + "comment": [ + "Used for every count other than 1. A language with one form for both translates this the same as the entry above." + ] + }, + "Adding {0} cell ({1})": "Adding {0} cell ({1})", + "Updating cell {0}": "Updating cell {0}", + "Removing cell {0}": "Removing cell {0}", + "Remove cell": "Remove cell", + "Remove cell **{0}** from the notebook?": "Remove cell **{0}** from the notebook?", + "Running cell {0}": "Running cell {0}", + "Running all cells": "Running all cells", + "Adding parameter \"{0}\" ({1})": "Adding parameter \"{0}\" ({1})", + "Updating parameter \"{0}\"": "Updating parameter \"{0}\"", + "Removing parameter \"{0}\"": "Removing parameter \"{0}\"", + "Remove parameter": "Remove parameter", + "Remove parameter **{0}** from the notebook?": "Remove parameter **{0}** from the notebook?", + "Setting \"{0}\" on cell {1}": "Setting \"{0}\" on cell {1}", + "Switching layout to \"{0}\"": "Switching layout to \"{0}\"", + "Moving cell {0} to position {1}": "Moving cell {0} to position {1}", + "Changing cell {0} to type \"{1}\"": "Changing cell {0} to type \"{1}\"", + "Changing cell {0} language to \"{1}\"": "Changing cell {0} language to \"{1}\"", + "No Verso notebook is currently open.": "No Verso notebook is currently open.", + "{0} cell/Used when {0} is 1. Paired with the entry below.": { + "message": "{0} cell", + "comment": [ + "Used when {0} is 1. Paired with the entry below." + ] + }, + "{0} cells/Used for every count other than 1. A language with one form for both translates this the same as the entry above.": { + "message": "{0} cells", + "comment": [ + "Used for every count other than 1. A language with one form for both translates this the same as the entry above." + ] + }, + "Cell {0} not found. The notebook has {1}.": "Cell {0} not found. The notebook has {1}.", + "The notebook is empty.": "The notebook is empty.", + "Cell {0}/A heading over one cell's code. {0} counts from 1.": { + "message": "Cell {0}", + "comment": [ + "A heading over one cell's code. {0} counts from 1." + ] + }, + "Running all cells...": "Running all cells...", + "Cell ({0}ms)/{0} is a number of milliseconds; ms is the unit and stays as written.": { + "message": "Cell ({0}ms)", + "comment": [ + "{0} is a number of milliseconds; ms is the unit and stays as written." + ] + }, + "Error: {0}/{0} is what the cell reported, in the language the kernel reported it.": { + "message": "Error: {0}", + "comment": [ + "{0} is what the cell reported, in the language the kernel reported it." + ] + }, + "No variables in scope. Run some cells first.": "No variables in scope. Run some cells first.", + "Name/A table heading: what a variable is called.": { + "message": "Name", + "comment": [ + "A table heading: what a variable is called." + ] + }, + "Type/A table heading: what kind of value a variable holds.": { + "message": "Type", + "comment": [ + "A table heading: what kind of value a variable holds." + ] + }, + "Value/A table heading: what a variable currently holds.": { + "message": "Value", + "comment": [ + "A table heading: what a variable currently holds." + ] + }, + "Usage: `/props ` (1-based). Example: `/props 2`/Shown when /props was typed without a cell number. Everything in backticks is typed and stays as written.": { + "message": "Usage: `/props ` (1-based). Example: `/props 2`", + "comment": [ + "Shown when /props was typed without a cell number. Everything in backticks is typed and stays as written." + ] + }, + "Cell {0} has no configurable properties.": "Cell {0} has no configurable properties.", + "**Cell {0}** [{1}] properties:": "**Cell {0}** [{1}] properties:", + "Property/A table heading: the name of one setting on a cell.": { + "message": "Property", + "comment": [ + "A table heading: the name of one setting on a cell." + ] + }, + "Type/A table heading: what kind of value a setting takes.": { + "message": "Type", + "comment": [ + "A table heading: what kind of value a setting takes." + ] + }, + "Value/A table heading: what a setting is currently set to.": { + "message": "Value", + "comment": [ + "A table heading: what a setting is currently set to." + ] + }, + "Read-only/A table heading: whether a setting can be changed.": { + "message": "Read-only", + "comment": [ + "A table heading: whether a setting can be changed." + ] + }, + "(not set)/Stands in a table cell for a setting that has no value yet.": { + "message": "(not set)", + "comment": [ + "Stands in a table cell for a setting that has no value yet." + ] + }, + "Yes/A table cell answering whether a setting is read-only.": { + "message": "Yes", + "comment": [ + "A table cell answering whether a setting is read-only." + ] + }, + "No/A table cell answering whether a setting is read-only. The answer to a question, not the word for a number.": { + "message": "No", + "comment": [ + "A table cell answering whether a setting is read-only. The answer to a question, not the word for a number." + ] + }, + "Provider: {0}/Names the extension a group of settings came from. {0} is its id.": { + "message": "Provider: {0}", + "comment": [ + "Names the extension a group of settings came from. {0} is its id." + ] + }, + "No Verso notebook is currently open. Open a `.verso`, `.ipynb`, `.md`, or `.dib` file first.": "No Verso notebook is currently open. Open a `.verso`, `.ipynb`, `.md`, or `.dib` file first.", + "Model error: {0}": "Model error: {0}", + "Verso: the notebooks already open keep their current language. Reopen them to read them in the new one.": "Verso: the notebooks already open keep their current language. Reopen them to read them in the new one.", + "Verso: Could not create a scratch notebook: {0}": "Verso: Could not create a scratch notebook: {0}", + "Verso: Failed to open notebook: {0}": "Verso: Failed to open notebook: {0}", + "Restart aborted: the notebook snapshot could not be captured ({0}).": "Restart aborted: the notebook snapshot could not be captured ({0}).", + "Verso: kernel restart aborted because the notebook snapshot could not be captured. Save and reopen the file.": "Verso: kernel restart aborted because the notebook snapshot could not be captured. Save and reopen the file.", + "Kernel restart failed: the host process did not start ({0}). Close and reopen the notebook.": "Kernel restart failed: the host process did not start ({0}). Close and reopen the notebook.", + "Kernel restart failed: the notebook did not reopen ({0}).": "Kernel restart failed: the notebook did not reopen ({0}).", + "Verso: kernel restart failed (notebook did not reopen): {0}. Close and reopen the notebook.": "Verso: kernel restart failed (notebook did not reopen): {0}. Close and reopen the notebook.", + "Export failed: {0}": "Export failed: {0}", + "Input request failed: {0}": "Input request failed: {0}", + "Install Extension/The button that accepts the chosen file, in place of \"Open\".": { + "message": "Install Extension", + "comment": [ + "The button that accepts the chosen file, in place of \"Open\"." + ] + }, + "Extensions/Names the kind of file the box will accept.": { + "message": "Extensions", + "comment": [ + "Names the kind of file the box will accept." + ] + }, + "Exported to {0}": "Exported to {0}", + "Notebook input/Asked when a running cell wants something typed and did not say what.": { + "message": "Notebook input", + "comment": [ + "Asked when a running cell wants something typed and did not say what." + ] + }, + "{0} Files/Names the kind of file a save box will accept. {0} is a format name such as CSV or HTML and is the same word in every language.": { + "message": "{0} Files", + "comment": [ + "Names the kind of file a save box will accept. {0} is a format name such as CSV or HTML and is the same word in every language." + ] + }, + "Images/Names the kind of file a save box will accept: pictures.": { + "message": "Images", + "comment": [ + "Names the kind of file a save box will accept: pictures." + ] + }, + "All Files/The entry in a save box that accepts any file at all.": { + "message": "All Files", + "comment": [ + "The entry in a save box that accepts any file at all." + ] + }, + "The notebook file is not inside a git repository.": "The notebook file is not inside a git repository.", + "Last Saved/One of the things a notebook can be compared against: the copy currently on disk.": { + "message": "Last Saved", + "comment": [ + "One of the things a notebook can be compared against: the copy currently on disk." + ] + }, + "The notebook has no file on disk yet.": "The notebook has no file on disk yet.", + "Git: HEAD/One of the things a notebook can be compared against. Git and HEAD are version control terms and are not translated.": { + "message": "Git: HEAD", + "comment": [ + "One of the things a notebook can be compared against. Git and HEAD are version control terms and are not translated." + ] + }, + "Git: Compare with Ref.../Opens a box for choosing a branch, tag, or commit. Git and Ref are version control terms and are not translated. Keep the three dots, which mean a question follows.": { + "message": "Git: Compare with Ref...", + "comment": [ + "Opens a box for choosing a branch, tag, or commit. Git and Ref are version control terms and are not translated. Keep the three dots, which mean a question follows." + ] + }, + "Choose File.../Opens a box for picking another notebook to compare against. Keep the three dots, which mean a question follows.": { + "message": "Choose File...", + "comment": [ + "Opens a box for picking another notebook to compare against. Keep the three dots, which mean a question follows." + ] + }, + "Type a ref or commit.../The last entry in a list of branches and tags, for naming one that is not listed. Ref and commit are version control terms.": { + "message": "Type a ref or commit...", + "comment": [ + "The last entry in a list of branches and tags, for naming one that is not listed. Ref and commit are version control terms." + ] + }, + "Git branch, tag, or commit SHA/Says what may be typed. Every term here is a version control term and stays as written.": { + "message": "Git branch, tag, or commit SHA", + "comment": [ + "Says what may be typed. Every term here is a version control term and stays as written." + ] + }, + "Compare/The button that accepts the chosen file, in place of \"Open\".": { + "message": "Compare", + "comment": [ + "The button that accepts the chosen file, in place of \"Open\"." + ] + }, + "Notebook Files/Names the kind of file the box will accept.": { + "message": "Notebook Files", + "comment": [ + "Names the kind of file the box will accept." + ] + }, + "Unknown comparison source '{0}'.": "Unknown comparison source '{0}'." +} \ No newline at end of file diff --git a/vscode/l10n/bundle.l10n.qps-ploc.json b/vscode/l10n/bundle.l10n.qps-ploc.json new file mode 100644 index 00000000..86e7bd90 --- /dev/null +++ b/vscode/l10n/bundle.l10n.qps-ploc.json @@ -0,0 +1,96 @@ +{ + "'{0}' is not a known branch, tag, or commit.": "[!!'{0}' ïš ñòt à kñòwñ bràñçh, tàg, òr çòmmït.···!!]", + "'{0}' is not tracked at '{1}'. Commit the file first, or pick a different ref.": "[!!'{0}' ïš ñòt tràçkéd àt '{1}'. Çòmmït thé fïlé fïršt, òr pïçk à dïfféréñt réf.···!!]", + "(not set)/Stands in a table cell for a setting that has no value yet.": "[!!(ñòt šét)···!!]", + "(unnamed)/Stands in for a branch or tag that has no name to show.": "[!!(ùññàméd)···!!]", + "**Cell {0}** [{1}] properties:": "[!!**Çéll {0}** [{1}] pròpértïéš:···!!]", + "Adding parameter \"{0}\" ({1})": "[!!Àddïñg pàràmétér \"{0}\" ({1})···!!]", + "Adding {0} cell ({1})": "[!!Àddïñg {0} çéll ({1})···!!]", + "All Files/The entry in a save box that accepts any file at all.": "[!!Àll Fïléš···!!]", + "Cell ({0}ms)/{0} is a number of milliseconds; ms is the unit and stays as written.": "[!!Çéll ({0}mš)···!!]", + "Cell {0} has no configurable properties.": "[!!Çéll {0} hàš ñò çòñfïgùràblé pròpértïéš.···!!]", + "Cell {0} not found. The notebook has {1}.": "[!!Çéll {0} ñòt fòùñd. Thé ñòtébòòk hàš {1}.···!!]", + "Cell {0}/A heading over one cell's code. {0} counts from 1.": "[!!Çéll {0}···!!]", + "Changing cell {0} language to \"{1}\"": "[!!Çhàñgïñg çéll {0} làñgùàgé tò \"{1}\"···!!]", + "Changing cell {0} to type \"{1}\"": "[!!Çhàñgïñg çéll {0} tò tÿpé \"{1}\"···!!]", + "Choose File.../Opens a box for picking another notebook to compare against. Keep the three dots, which mean a question follows.": "[!!Çhòòšé Fïlé...···!!]", + "Compare notebook with...": "[!!Çòmpàré ñòtébòòk wïth...···!!]", + "Compare/The button that accepts the chosen file, in place of \"Open\".": "[!!Çòmpàré···!!]", + "Error: {0}/{0} is what the cell reported, in the language the kernel reported it.": "[!!Érròr: {0}···!!]", + "Export failed: {0}": "[!!Éxpòrt fàïléd: {0}···!!]", + "Exported to {0}": "[!!Éxpòrtéd tò {0}···!!]", + "Extensions/Names the kind of file the box will accept.": "[!!Éxtéñšïòñš···!!]", + "Git branch, tag, or commit SHA/Says what may be typed. Every term here is a version control term and stays as written.": "[!!Gït bràñçh, tàg, òr çòmmït ŠHÀ···!!]", + "Git: Compare with Ref.../Opens a box for choosing a branch, tag, or commit. Git and Ref are version control terms and are not translated. Keep the three dots, which mean a question follows.": "[!!Gït: Çòmpàré wïth Réf...···!!]", + "Git: HEAD/One of the things a notebook can be compared against. Git and HEAD are version control terms and are not translated.": "[!!Gït: HÉÀD···!!]", + "Images/Names the kind of file a save box will accept: pictures.": "[!!Ïmàgéš···!!]", + "Input request failed: {0}": "[!!Ïñpùt réqùéšt fàïléd: {0}···!!]", + "Install .NET Runtime/A button. .NET is a product name and stays as written.": "[!!Ïñštàll .ÑÉT Rùñtïmé···!!]", + "Install Extension/The button that accepts the chosen file, in place of \"Open\".": "[!!Ïñštàll Éxtéñšïòñ···!!]", + "Kernel restart failed: the host process did not start ({0}). Close and reopen the notebook.": "[!!Kérñél réštàrt fàïléd: thé hòšt pròçéšš dïd ñòt štàrt ({0}). Çlòšé àñd réòpéñ thé ñòtébòòk.···!!]", + "Kernel restart failed: the notebook did not reopen ({0}).": "[!!Kérñél réštàrt fàïléd: thé ñòtébòòk dïd ñòt réòpéñ ({0}).···!!]", + "Last Saved/One of the things a notebook can be compared against: the copy currently on disk.": "[!!Làšt Šàvéd···!!]", + "Model error: {0}": "[!!Mòdél érròr: {0}···!!]", + "Moving cell {0} to position {1}": "[!!Mòvïñg çéll {0} tò pòšïtïòñ {1}···!!]", + "Name/A table heading: what a variable is called.": "[!!Ñàmé···!!]", + "No Verso notebook is currently open.": "[!!Ñò Véršò ñòtébòòk ïš çùrréñtlÿ òpéñ.···!!]", + "No Verso notebook is currently open. Open a `.verso`, `.ipynb`, `.md`, or `.dib` file first.": "[!!Ñò Véršò ñòtébòòk ïš çùrréñtlÿ òpéñ. Òpéñ à `.véršò`, `.ïpÿñb`, `.md`, òr `.dïb` fïlé fïršt.···!!]", + "No variables in scope. Run some cells first.": "[!!Ñò vàrïàbléš ïñ šçòpé. Rùñ šòmé çéllš fïršt.···!!]", + "No/A table cell answering whether a setting is read-only. The answer to a question, not the word for a number.": "[!!Ñò···!!]", + "Notebook Files/Names the kind of file the box will accept.": "[!!Ñòtébòòk Fïléš···!!]", + "Notebook input/Asked when a running cell wants something typed and did not say what.": "[!!Ñòtébòòk ïñpùt···!!]", + "Property/A table heading: the name of one setting on a cell.": "[!!Pròpértÿ···!!]", + "Provider: {0}/Names the extension a group of settings came from. {0} is its id.": "[!!Pròvïdér: {0}···!!]", + "Read-only/A table heading: whether a setting can be changed.": "[!!Réàd-òñlÿ···!!]", + "Remove cell": "[!!Rémòvé çéll···!!]", + "Remove cell **{0}** from the notebook?": "[!!Rémòvé çéll **{0}** fròm thé ñòtébòòk?···!!]", + "Remove parameter": "[!!Rémòvé pàràmétér···!!]", + "Remove parameter **{0}** from the notebook?": "[!!Rémòvé pàràmétér **{0}** fròm thé ñòtébòòk?···!!]", + "Removing cell {0}": "[!!Rémòvïñg çéll {0}···!!]", + "Removing parameter \"{0}\"": "[!!Rémòvïñg pàràmétér \"{0}\"···!!]", + "Restart aborted: the notebook snapshot could not be captured ({0}).": "[!!Réštàrt àbòrtéd: thé ñòtébòòk šñàpšhòt çòùld ñòt bé çàptùréd ({0}).···!!]", + "Running all cells": "[!!Rùññïñg àll çéllš···!!]", + "Running all cells...": "[!!Rùññïñg àll çéllš...···!!]", + "Running cell {0}": "[!!Rùññïñg çéll {0}···!!]", + "Select a notebook for @verso/@verso is typed to address the assistant and stays as written.": "[!!Šéléçt à ñòtébòòk fòr @véršò···!!]", + "Setting \"{0}\" on cell {1}": "[!!Šéttïñg \"{0}\" òñ çéll {1}···!!]", + "Setup Help/A button. It opens the page describing how to set Verso up.": "[!!Šétùp Hélp···!!]", + "Switching layout to \"{0}\"": "[!!Šwïtçhïñg làÿòùt tò \"{0}\"···!!]", + "The notebook file is not inside a git repository.": "[!!Thé ñòtébòòk fïlé ïš ñòt ïñšïdé à gït répòšïtòrÿ.···!!]", + "The notebook has no file on disk yet.": "[!!Thé ñòtébòòk hàš ñò fïlé òñ dïšk ÿét.···!!]", + "The notebook is empty.": "[!!Thé ñòtébòòk ïš émptÿ.···!!]", + "This comparison source is not available.": "[!!Thïš çòmpàrïšòñ šòùrçé ïš ñòt àvàïlàblé.···!!]", + "This notebook is not inside a git repository.": "[!!Thïš ñòtébòòk ïš ñòt ïñšïdé à gït répòšïtòrÿ.···!!]", + "Type a ref or commit.../The last entry in a list of branches and tags, for naming one that is not listed. Ref and commit are version control terms.": "[!!Tÿpé à réf òr çòmmït...···!!]", + "Type/A table heading: what kind of value a setting takes.": "[!!Tÿpé···!!]", + "Type/A table heading: what kind of value a variable holds.": "[!!Tÿpé···!!]", + "Unknown comparison source '{0}'.": "[!!Ùñkñòwñ çòmpàrïšòñ šòùrçé '{0}'.···!!]", + "Updating cell {0}": "[!!Ùpdàtïñg çéll {0}···!!]", + "Updating parameter \"{0}\"": "[!!Ùpdàtïñg pàràmétér \"{0}\"···!!]", + "Usage: `/props ` (1-based). Example: `/props 2`/Shown when /props was typed without a cell number. Everything in backticks is typed and stays as written.": "[!!Ùšàgé: `/pròpš <çéll ñùmbér>` (1-bàšéd). Éxàmplé: `/pròpš 2`···!!]", + "Value/A table heading: what a setting is currently set to.": "[!!Vàlùé···!!]", + "Value/A table heading: what a variable currently holds.": "[!!Vàlùé···!!]", + "Verso host process exited ({0})": "[!!Véršò hòšt pròçéšš éxïtéd ({0})···!!]", + "Verso needs the .NET runtime (version {0} or later) to run notebooks, but a compatible installation was not found.": "[!!Véršò ñéédš thé .ÑÉT rùñtïmé (véršïòñ {0} òr làtér) tò rùñ ñòtébòòkš, bùt à çòmpàtïblé ïñštàllàtïòñ wàš ñòt fòùñd.···!!]", + "Verso: Could not create a scratch notebook: {0}": "[!!Véršò: Çòùld ñòt çréàté à šçràtçh ñòtébòòk: {0}···!!]", + "Verso: Could not find Verso.Host.dll. Set \"verso.hostPath\" in settings to the path of your built Verso.Host.dll.": "[!!Véršò: Çòùld ñòt fïñd Véršò.Hòšt.dll. Šét \"véršò.hòštPàth\" ïñ šéttïñgš tò thé pàth òf ÿòùr bùïlt Véršò.Hòšt.dll.···!!]", + "Verso: Failed to open notebook: {0}": "[!!Véršò: Fàïléd tò òpéñ ñòtébòòk: {0}···!!]", + "Verso: Failed to start host process: {0}": "[!!Véršò: Fàïléd tò štàrt hòšt pròçéšš: {0}···!!]", + "Verso: Open a notebook to compare it with a baseline.": "[!!Véršò: Òpéñ à ñòtébòòk tò çòmpàré ït wïth à bàšélïñé.···!!]", + "Verso: installing the .NET runtime...": "[!!Véršò: ïñštàllïñg thé .ÑÉT rùñtïmé...···!!]", + "Verso: kernel restart aborted because the notebook snapshot could not be captured. Save and reopen the file.": "[!!Véršò: kérñél réštàrt àbòrtéd béçàùšé thé ñòtébòòk šñàpšhòt çòùld ñòt bé çàptùréd. Šàvé àñd réòpéñ thé fïlé.···!!]", + "Verso: kernel restart failed (notebook did not reopen): {0}. Close and reopen the notebook.": "[!!Véršò: kérñél réštàrt fàïléd (ñòtébòòk dïd ñòt réòpéñ): {0}. Çlòšé àñd réòpéñ thé ñòtébòòk.···!!]", + "Verso: the .NET runtime is installed. Reopen the notebook to continue.": "[!!Véršò: thé .ÑÉT rùñtïmé ïš ïñštàlléd. Réòpéñ thé ñòtébòòk tò çòñtïñùé.···!!]", + "Verso: the notebooks already open keep their current language. Reopen them to read them in the new one.": "[!!Véršò: thé ñòtébòòkš àlréàdÿ òpéñ kéép théïr çùrréñt làñgùàgé. Réòpéñ thém tò réàd thém ïñ thé ñéw òñé.···!!]", + "Yes/A table cell answering whether a setting is read-only.": "[!!Ýéš···!!]", + "branch/Says what kind of thing is listed, shown beside its name. A line of work in version control.": "[!!bràñçh···!!]", + "git could not read '{0}' at '{1}': {2}": "[!!gït çòùld ñòt réàd '{0}' àt '{1}': {2}···!!]", + "remote branch/A branch that lives on the server rather than on this machine.": "[!!rémòté bràñçh···!!]", + "tag/A name pinned to one point in a project's history.": "[!!tàg···!!]", + "unavailable/Said of a baseline that cannot be compared against, for a reason nothing here knows.": "[!!ùñàvàïlàblé···!!]", + "{0} Files/Names the kind of file a save box will accept. {0} is a format name such as CSV or HTML and is the same word in every language.": "[!!{0} Fïléš···!!]", + "{0} cell/Used when {0} is 1. Paired with the entry below.": "[!!{0} çéll···!!]", + "{0} cells/Used for every count other than 1. A language with one form for both translates this the same as the entry above.": "[!!{0} çéllš···!!]", + "{0} line/Used when {0} is 1. Paired with the entry below.": "[!!{0} lïñé···!!]", + "{0} lines/Used for every count other than 1. A language with one form for both translates this the same as the entry above.": "[!!{0} lïñéš···!!]" +} diff --git a/vscode/package.json b/vscode/package.json index 42c649e5..ddafdd14 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -1,7 +1,7 @@ { "name": "verso-notebook", "displayName": "Verso Notebook", - "description": "Polyglot .NET notebooks with C#, F#, Python, JavaScript, TypeScript, PowerShell, SQL, and HTTP cells. One shared variable store across languages, IntelliSense, dashboards, Jupyter import, and GitHub Copilot integration.", + "description": "%extension.description%", "version": "1.0.0", "publisher": "datafication", "license": "MIT", @@ -73,22 +73,23 @@ "onCommand:verso.openInVerso" ], "main": "./dist/extension.js", + "l10n": "./l10n", "contributes": { "commands": [ { "command": "verso.newNotebook", - "title": "New Notebook", + "title": "%command.newNotebook.title%", "category": "Verso" }, { "command": "verso.compareWithBaseline", - "title": "Compare Notebook with...", + "title": "%command.compareWithBaseline.title%", "category": "Verso", "icon": "$(diff)" }, { "command": "verso.openInVerso", - "title": "Open as Verso Notebook", + "title": "%command.openInVerso.title%", "category": "Verso" } ], @@ -152,17 +153,17 @@ "verso.hostPath": { "type": "string", "default": "", - "description": "Path to the Verso.Host.dll. If empty, uses bundled host." + "description": "%configuration.hostPath.description%" }, "verso.dotnetPath": { "type": "string", "default": "", - "description": "Path to the 'dotnet' executable used to run notebooks. If empty, Verso reuses an installed .NET runtime (locating it via the .NET Install Tool when available) and falls back to 'dotnet' on PATH." + "description": "%configuration.dotnetPath.description%" }, "verso.python.interpreterPath": { "type": "string", "default": "", - "description": "Path to the Python interpreter used by Python cells. If empty, Verso discovers one from the active virtual environment, the workspace, and well-known install locations. Changes apply on next notebook open or kernel restart." + "description": "%configuration.python.interpreterPath.description%" }, "verso.python.autoInstall": { "type": "string", @@ -172,17 +173,38 @@ "off" ], "enumDescriptions": [ - "Ask before installing, listing the exact packages and the environment they go into. Declining runs the cell anyway.", - "Install known packages without asking. A package name Verso can only guess at is reported instead of installed.", - "Never scan a cell's imports and never install." + "%configuration.python.autoInstall.prompt%", + "%configuration.python.autoInstall.auto%", + "%configuration.python.autoInstall.off%" ], "default": "prompt", - "description": "What happens when a Python cell imports a package the environment does not have. Changes apply on next notebook open or kernel restart." + "description": "%configuration.python.autoInstall.description%" }, "verso.python.useUv": { "type": "boolean", "default": true, - "description": "Use the uv tool to install packages and create Python environments when it is on PATH. When off, pip and the standard library venv module are used. Changes apply on next notebook open or kernel restart." + "description": "%configuration.python.useUv.description%" + }, + "verso.language": { + "type": "string", + "enum": [ + "auto", + "en", + "de", + "es", + "ja", + "zh-Hans" + ], + "enumItemLabels": [ + "%configuration.language.auto%", + "English", + "Deutsch", + "Español", + "日本語", + "简体中文" + ], + "default": "auto", + "description": "%configuration.language.description%" }, "verso.extensionsPath": { "type": "array", @@ -190,17 +212,17 @@ "type": "string" }, "default": [], - "description": "Directories of third-party Verso extension assemblies to load on notebook open. Each entry is one directory path. Changes apply on next notebook open or kernel restart." + "description": "%configuration.extensionsPath.description%" }, "verso.preserveOriginalFormat": { "type": "boolean", "default": false, - "description": "When opening an .ipynb file, save changes back to .ipynb instead of converting to .verso. Cell outputs are preserved. Default off keeps the existing convert-on-save behavior. Markdown (.md) notebooks always save back to .md regardless of this setting." + "description": "%configuration.preserveOriginalFormat.description%" }, "verso.showOpenInVersoMenu": { "type": "boolean", "default": true, - "description": "Show the 'Open as Verso Notebook' entry in the Explorer context menu for Markdown (.md) files. Disabling this hides the menu entry only; .md files can still be opened in Verso through the editor's 'Reopen Editor With...' picker." + "description": "%configuration.showOpenInVersoMenu.description%" } } }, @@ -209,24 +231,24 @@ "id": "verso.copilot.notebook", "name": "verso", "fullName": "Verso Notebook", - "description": "Create, edit, run, and explore Verso notebook cells", + "description": "%chat.participant.description%", "isSticky": true, "commands": [ { "name": "cells", - "description": "List all cells in the active notebook" + "description": "%chat.cells.description%" }, { "name": "run", - "description": "Run all cells in the active notebook" + "description": "%chat.run.description%" }, { "name": "vars", - "description": "Show variables in scope" + "description": "%chat.vars.description%" }, { "name": "props", - "description": "Show properties for a cell" + "description": "%chat.props.description%" } ] } @@ -235,7 +257,7 @@ { "name": "verso_listCells", "tags": ["verso", "notebook", "cells"], - "displayName": "List Cells", + "displayName": "%tool.listCells.displayName%", "modelDescription": "List all cells in the active Verso notebook, showing each cell's source code, language, and outputs.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -246,7 +268,7 @@ { "name": "verso_addCell", "tags": ["verso", "notebook", "cells"], - "displayName": "Add Cell", + "displayName": "%tool.addCell.displayName%", "modelDescription": "Add a new cell to the Verso notebook. The 'type' field determines the cell kind. For code cells, also set 'language' to the kernel. For non-code types (markdown, html, mermaid, etc.), only type is needed. Refer to the system prompt for available cell types and languages.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -275,7 +297,7 @@ { "name": "verso_updateCell", "tags": ["verso", "notebook", "cells"], - "displayName": "Update Cell", + "displayName": "%tool.updateCell.displayName%", "modelDescription": "Update the source code of an existing cell. Use the 1-based cell number (call verso_listCells first to find it).", "canBeReferencedInPrompt": false, "inputSchema": { @@ -296,7 +318,7 @@ { "name": "verso_removeCell", "tags": ["verso", "notebook", "cells"], - "displayName": "Remove Cell", + "displayName": "%tool.removeCell.displayName%", "modelDescription": "Remove a cell from the notebook by its 1-based cell number.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -313,7 +335,7 @@ { "name": "verso_runCell", "tags": ["verso", "notebook", "execution"], - "displayName": "Run Cell", + "displayName": "%tool.runCell.displayName%", "modelDescription": "Execute a specific cell by its 1-based cell number and return the output.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -330,7 +352,7 @@ { "name": "verso_runAll", "tags": ["verso", "notebook", "execution"], - "displayName": "Run All Cells", + "displayName": "%tool.runAll.displayName%", "modelDescription": "Execute all cells in the notebook sequentially and return results.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -341,7 +363,7 @@ { "name": "verso_listVariables", "tags": ["verso", "notebook", "variables"], - "displayName": "List Variables", + "displayName": "%tool.listVariables.displayName%", "modelDescription": "List all variables currently in scope in the notebook kernel, showing name, type, and value preview.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -352,7 +374,7 @@ { "name": "verso_inspectVariable", "tags": ["verso", "notebook", "variables"], - "displayName": "Inspect Variable", + "displayName": "%tool.inspectVariable.displayName%", "modelDescription": "Get the detailed value of a specific variable by name.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -369,7 +391,7 @@ { "name": "verso_getLanguages", "tags": ["verso", "notebook"], - "displayName": "Get Languages", + "displayName": "%tool.getLanguages.displayName%", "modelDescription": "List the available languages/kernels for the active notebook.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -380,7 +402,7 @@ { "name": "verso_listParameters", "tags": ["verso", "notebook", "parameters"], - "displayName": "List Parameters", + "displayName": "%tool.listParameters.displayName%", "modelDescription": "List all parameters defined in the notebook. Parameters are named, typed values (string, int, float, bool, date, datetime) that can be set before execution to control notebook behavior.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -391,7 +413,7 @@ { "name": "verso_addParameter", "tags": ["verso", "notebook", "parameters"], - "displayName": "Add Parameter", + "displayName": "%tool.addParameter.displayName%", "modelDescription": "Add a new parameter to the notebook. Supported types: string, int, float, bool, date (yyyy-MM-dd), datetime (ISO 8601). A parameters cell is automatically created if one does not exist.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -425,7 +447,7 @@ { "name": "verso_updateParameter", "tags": ["verso", "notebook", "parameters"], - "displayName": "Update Parameter", + "displayName": "%tool.updateParameter.displayName%", "modelDescription": "Update an existing parameter's properties. Only the fields you provide will be changed. Use verso_listParameters first to see current parameters.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -459,7 +481,7 @@ { "name": "verso_removeParameter", "tags": ["verso", "notebook", "parameters"], - "displayName": "Remove Parameter", + "displayName": "%tool.removeParameter.displayName%", "modelDescription": "Remove a parameter from the notebook by name. This also removes the corresponding variable from the kernel.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -476,7 +498,7 @@ { "name": "verso_getCellProperties", "tags": ["verso", "notebook", "properties"], - "displayName": "Get Cell Properties", + "displayName": "%tool.getCellProperties.displayName%", "modelDescription": "Get the configurable properties for a specific cell. Properties are extension-provided settings like visibility, formatting, or tags. Call this before verso_updateCellProperty to discover available properties and their current values. The response includes providerExtensionId values needed for updates.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -493,7 +515,7 @@ { "name": "verso_updateCellProperty", "tags": ["verso", "notebook", "properties"], - "displayName": "Update Cell Property", + "displayName": "%tool.updateCellProperty.displayName%", "modelDescription": "Update a property on a cell. Call verso_getCellProperties first to discover the available properties, their field types, and the providerExtensionId needed for this call. For Select fields, use the option value (not display name). For Toggle fields, use 'true' or 'false'.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -522,7 +544,7 @@ { "name": "verso_listLayouts", "tags": ["verso", "notebook", "layout"], - "displayName": "List Layouts", + "displayName": "%tool.listLayouts.displayName%", "modelDescription": "List the layouts registered for this notebook (for example Notebook or Dashboard). Each entry includes its displayName, the extensionId and layoutId needed by verso_switchLayout, and whether it is currently active. Call this before verso_switchLayout to discover the qualified identity of the target layout.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -533,7 +555,7 @@ { "name": "verso_switchLayout", "tags": ["verso", "notebook", "layout"], - "displayName": "Switch Layout", + "displayName": "%tool.switchLayout.displayName%", "modelDescription": "Switch the notebook's active layout. Call verso_listLayouts first to get the extensionId and layoutId of the target layout. The choice is saved with the notebook. Some layouts (such as Dashboard) present cells differently, for example output-only with grid positions.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -554,7 +576,7 @@ { "name": "verso_moveCell", "tags": ["verso", "notebook", "cells"], - "displayName": "Move Cell", + "displayName": "%tool.moveCell.displayName%", "modelDescription": "Move a cell to a different position in the notebook. Both numbers are 1-based. Call verso_listCells first to confirm the current order.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -575,7 +597,7 @@ { "name": "verso_changeCellType", "tags": ["verso", "notebook", "cells"], - "displayName": "Change Cell Type", + "displayName": "%tool.changeCellType.displayName%", "modelDescription": "Change a cell's type (for example code, markdown, html, mermaid). Call verso_getLanguages or verso_listCells to see available types. Changing the type clears the cell's outputs. For code cells the language is set automatically; use verso_changeCellLanguage to choose a different one.", "canBeReferencedInPrompt": false, "inputSchema": { @@ -596,7 +618,7 @@ { "name": "verso_changeCellLanguage", "tags": ["verso", "notebook", "cells"], - "displayName": "Change Cell Language", + "displayName": "%tool.changeCellLanguage.displayName%", "modelDescription": "Change the kernel language of a code cell (for example csharp, sql, python). Call verso_getLanguages to list valid languages. Changing the language clears the cell's outputs.", "canBeReferencedInPrompt": false, "inputSchema": { diff --git a/vscode/package.nls.json b/vscode/package.nls.json new file mode 100644 index 00000000..5386c5ab --- /dev/null +++ b/vscode/package.nls.json @@ -0,0 +1,116 @@ +{ + "extension.description": { + "message": "Polyglot .NET notebooks with C#, F#, Python, JavaScript, TypeScript, PowerShell, SQL, and HTTP cells. One shared variable store across languages, IntelliSense, dashboards, Jupyter import, and GitHub Copilot integration.", + "comment": [ + "The blurb under the extension's name in the Marketplace.", + "Every language name and product name here is written the same in every language." + ] + }, + + "command.newNotebook.title": { + "message": "New Notebook", + "comment": ["A Command Palette entry. It creates a notebook that has no file yet."] + }, + "command.compareWithBaseline.title": { + "message": "Compare Notebook with...", + "comment": [ + "A Command Palette entry. It asks which earlier copy of the notebook to compare against.", + "Keep the three dots, which mean a question follows." + ] + }, + "command.openInVerso.title": { + "message": "Open as Verso Notebook", + "comment": [ + "Offered when right-clicking a Markdown file. It opens that file as a notebook.", + "\"Verso Notebook\" is the product's name and is written the same in every language." + ] + }, + + "configuration.hostPath.description": { + "message": "Path to the Verso.Host.dll. If empty, uses bundled host.", + "comment": ["Verso.Host.dll is a file name and is written the same in every language."] + }, + "configuration.dotnetPath.description": { + "message": "Path to the 'dotnet' executable used to run notebooks. If empty, Verso reuses an installed .NET runtime (locating it via the .NET Install Tool when available) and falls back to 'dotnet' on PATH.", + "comment": [ + "'dotnet', .NET, and PATH are written the same in every language.", + "\".NET Install Tool\" is the name of another extension; leave it as written so it can be found." + ] + }, + "configuration.python.interpreterPath.description": { + "message": "Path to the Python interpreter used by Python cells. If empty, Verso discovers one from the active virtual environment, the workspace, and well-known install locations. Changes apply on next notebook open or kernel restart." + }, + "configuration.python.autoInstall.description": { + "message": "What happens when a Python cell imports a package the environment does not have. Changes apply on next notebook open or kernel restart." + }, + "configuration.python.autoInstall.prompt": { + "message": "Ask before installing, listing the exact packages and the environment they go into. Declining runs the cell anyway." + }, + "configuration.python.autoInstall.auto": { + "message": "Install known packages without asking. A package name Verso can only guess at is reported instead of installed." + }, + "configuration.python.autoInstall.off": { + "message": "Never scan a cell's imports and never install." + }, + "configuration.python.useUv.description": { + "message": "Use the uv tool to install packages and create Python environments when it is on PATH. When off, pip and the standard library venv module are used. Changes apply on next notebook open or kernel restart.", + "comment": ["uv, pip, venv, and PATH are names typed at a keyboard and stay as written."] + }, + "configuration.language.description": { + "message": "Language for the notebook interface and kernel messages. Auto-detect follows the VS Code display language. Menu entries, command names, and setting descriptions always follow the VS Code display language and are not affected by this setting. Changes apply on next notebook open.", + "comment": [ + "\"Auto-detect\" here names the first entry in this setting's own list; translate it the same way in both places." + ] + }, + "configuration.language.auto": { + "message": "Auto-detect", + "comment": [ + "The first entry in the language list: take the language from the editor rather than choosing one.", + "The other entries are the names languages call themselves, so they are the same in every list and are not translated." + ] + }, + "configuration.extensionsPath.description": { + "message": "Directories of third-party Verso extension assemblies to load on notebook open. Each entry is one directory path. Changes apply on next notebook open or kernel restart." + }, + "configuration.preserveOriginalFormat.description": { + "message": "When opening an .ipynb file, save changes back to .ipynb instead of converting to .verso. Cell outputs are preserved. Default off keeps the existing convert-on-save behavior. Markdown (.md) notebooks always save back to .md regardless of this setting.", + "comment": ["File extensions stay exactly as spelled."] + }, + "configuration.showOpenInVersoMenu.description": { + "message": "Show the 'Open as Verso Notebook' entry in the Explorer context menu for Markdown (.md) files. Disabling this hides the menu entry only; .md files can still be opened in Verso through the editor's 'Reopen Editor With...' picker.", + "comment": [ + "'Open as Verso Notebook' names the menu entry above; translate it the same way in both places.", + "'Reopen Editor With...' is the editor's own wording; use whatever the editor calls it in this language." + ] + }, + + "chat.participant.description": { + "message": "Create, edit, run, and explore Verso notebook cells", + "comment": ["Shown beside @verso in the chat view. No full stop, matching the other participants."] + }, + "chat.cells.description": "List all cells in the active notebook", + "chat.run.description": "Run all cells in the active notebook", + "chat.vars.description": "Show variables in scope", + "chat.props.description": "Show properties for a cell", + + "tool.listCells.displayName": "List Cells", + "tool.addCell.displayName": "Add Cell", + "tool.updateCell.displayName": "Update Cell", + "tool.removeCell.displayName": "Remove Cell", + "tool.runCell.displayName": "Run Cell", + "tool.runAll.displayName": "Run All Cells", + "tool.listVariables.displayName": "List Variables", + "tool.inspectVariable.displayName": "Inspect Variable", + "tool.getLanguages.displayName": "Get Languages", + "tool.listParameters.displayName": "List Parameters", + "tool.addParameter.displayName": "Add Parameter", + "tool.updateParameter.displayName": "Update Parameter", + "tool.removeParameter.displayName": "Remove Parameter", + "tool.getCellProperties.displayName": "Get Cell Properties", + "tool.updateCellProperty.displayName": "Update Cell Property", + "tool.listLayouts.displayName": "List Layouts", + "tool.switchLayout.displayName": "Switch Layout", + "tool.moveCell.displayName": "Move Cell", + "tool.changeCellType.displayName": "Change Cell Type", + "tool.changeCellLanguage.displayName": "Change Cell Language" +} diff --git a/vscode/package.nls.qps-ploc.json b/vscode/package.nls.qps-ploc.json new file mode 100644 index 00000000..af91302c --- /dev/null +++ b/vscode/package.nls.qps-ploc.json @@ -0,0 +1,44 @@ +{ + "chat.cells.description": "[!!Lïšt àll çéllš ïñ thé àçtïvé ñòtébòòk···!!]", + "chat.participant.description": "[!!Çréàté, édït, rùñ, àñd éxplòré Véršò ñòtébòòk çéllš···!!]", + "chat.props.description": "[!!Šhòw pròpértïéš fòr à çéll···!!]", + "chat.run.description": "[!!Rùñ àll çéllš ïñ thé àçtïvé ñòtébòòk···!!]", + "chat.vars.description": "[!!Šhòw vàrïàbléš ïñ šçòpé···!!]", + "command.compareWithBaseline.title": "[!!Çòmpàré Ñòtébòòk wïth...···!!]", + "command.newNotebook.title": "[!!Ñéw Ñòtébòòk···!!]", + "command.openInVerso.title": "[!!Òpéñ àš Véršò Ñòtébòòk···!!]", + "configuration.dotnetPath.description": "[!!Pàth tò thé 'dòtñét' éxéçùtàblé ùšéd tò rùñ ñòtébòòkš. Ïf émptÿ, Véršò réùšéš àñ ïñštàlléd .ÑÉT rùñtïmé (lòçàtïñg ït vïà thé .ÑÉT Ïñštàll Tòòl whéñ àvàïlàblé) àñd fàllš bàçk tò 'dòtñét' òñ PÀTH.···!!]", + "configuration.extensionsPath.description": "[!!Dïréçtòrïéš òf thïrd-pàrtÿ Véršò éxtéñšïòñ àššémblïéš tò lòàd òñ ñòtébòòk òpéñ. Éàçh éñtrÿ ïš òñé dïréçtòrÿ pàth. Çhàñgéš àpplÿ òñ ñéxt ñòtébòòk òpéñ òr kérñél réštàrt.···!!]", + "configuration.hostPath.description": "[!!Pàth tò thé Véršò.Hòšt.dll. Ïf émptÿ, ùšéš bùñdléd hòšt.···!!]", + "configuration.language.auto": "[!!Àùtò-détéçt···!!]", + "configuration.language.description": "[!!Làñgùàgé fòr thé ñòtébòòk ïñtérfàçé àñd kérñél méššàgéš. Àùtò-détéçt fòllòwš thé VŠ Çòdé dïšplàÿ làñgùàgé. Méñù éñtrïéš, çòmmàñd ñàméš, àñd šéttïñg déšçrïptïòñš àlwàÿš fòllòw thé VŠ Çòdé dïšplàÿ làñgùàgé àñd àré ñòt àfféçtéd bÿ thïš šéttïñg. Çhàñgéš àpplÿ òñ ñéxt ñòtébòòk òpéñ.···!!]", + "configuration.preserveOriginalFormat.description": "[!!Whéñ òpéñïñg àñ .ïpÿñb fïlé, šàvé çhàñgéš bàçk tò .ïpÿñb ïñštéàd òf çòñvértïñg tò .véršò. Çéll òùtpùtš àré préšérvéd. Défàùlt òff kéépš thé éxïštïñg çòñvért-òñ-šàvé béhàvïòr. Màrkdòwñ (.md) ñòtébòòkš àlwàÿš šàvé bàçk tò .md régàrdléšš òf thïš šéttïñg.···!!]", + "configuration.python.autoInstall.auto": "[!!Ïñštàll kñòwñ pàçkàgéš wïthòùt àškïñg. À pàçkàgé ñàmé Véršò çàñ òñlÿ gùéšš àt ïš répòrtéd ïñštéàd òf ïñštàlléd.···!!]", + "configuration.python.autoInstall.description": "[!!Whàt hàppéñš whéñ à Pÿthòñ çéll ïmpòrtš à pàçkàgé thé éñvïròñméñt dòéš ñòt hàvé. Çhàñgéš àpplÿ òñ ñéxt ñòtébòòk òpéñ òr kérñél réštàrt.···!!]", + "configuration.python.autoInstall.off": "[!!Ñévér šçàñ à çéll'š ïmpòrtš àñd ñévér ïñštàll.···!!]", + "configuration.python.autoInstall.prompt": "[!!Àšk béfòré ïñštàllïñg, lïštïñg thé éxàçt pàçkàgéš àñd thé éñvïròñméñt théÿ gò ïñtò. Déçlïñïñg rùñš thé çéll àñÿwàÿ.···!!]", + "configuration.python.interpreterPath.description": "[!!Pàth tò thé Pÿthòñ ïñtérprétér ùšéd bÿ Pÿthòñ çéllš. Ïf émptÿ, Véršò dïšçòvérš òñé fròm thé àçtïvé vïrtùàl éñvïròñméñt, thé wòrkšpàçé, àñd wéll-kñòwñ ïñštàll lòçàtïòñš. Çhàñgéš àpplÿ òñ ñéxt ñòtébòòk òpéñ òr kérñél réštàrt.···!!]", + "configuration.python.useUv.description": "[!!Ùšé thé ùv tòòl tò ïñštàll pàçkàgéš àñd çréàté Pÿthòñ éñvïròñméñtš whéñ ït ïš òñ PÀTH. Whéñ òff, pïp àñd thé štàñdàrd lïbràrÿ véñv mòdùlé àré ùšéd. Çhàñgéš àpplÿ òñ ñéxt ñòtébòòk òpéñ òr kérñél réštàrt.···!!]", + "configuration.showOpenInVersoMenu.description": "[!!Šhòw thé 'Òpéñ àš Véršò Ñòtébòòk' éñtrÿ ïñ thé Éxplòrér çòñtéxt méñù fòr Màrkdòwñ (.md) fïléš. Dïšàblïñg thïš hïdéš thé méñù éñtrÿ òñlÿ; .md fïléš çàñ štïll bé òpéñéd ïñ Véršò thròùgh thé édïtòr'š 'Réòpéñ Édïtòr Wïth...' pïçkér.···!!]", + "extension.description": "[!!Pòlÿglòt .ÑÉT ñòtébòòkš wïth Ç#, F#, Pÿthòñ, JàvàŠçrïpt, TÿpéŠçrïpt, PòwérŠhéll, ŠQL, àñd HTTP çéllš. Òñé šhàréd vàrïàblé štòré àçròšš làñgùàgéš, ÏñtéllïŠéñšé, dàšhbòàrdš, Jùpÿtér ïmpòrt, àñd GïtHùb Çòpïlòt ïñtégràtïòñ.···!!]", + "tool.addCell.displayName": "[!!Àdd Çéll···!!]", + "tool.addParameter.displayName": "[!!Àdd Pàràmétér···!!]", + "tool.changeCellLanguage.displayName": "[!!Çhàñgé Çéll Làñgùàgé···!!]", + "tool.changeCellType.displayName": "[!!Çhàñgé Çéll Tÿpé···!!]", + "tool.getCellProperties.displayName": "[!!Gét Çéll Pròpértïéš···!!]", + "tool.getLanguages.displayName": "[!!Gét Làñgùàgéš···!!]", + "tool.inspectVariable.displayName": "[!!Ïñšpéçt Vàrïàblé···!!]", + "tool.listCells.displayName": "[!!Lïšt Çéllš···!!]", + "tool.listLayouts.displayName": "[!!Lïšt Làÿòùtš···!!]", + "tool.listParameters.displayName": "[!!Lïšt Pàràmétérš···!!]", + "tool.listVariables.displayName": "[!!Lïšt Vàrïàbléš···!!]", + "tool.moveCell.displayName": "[!!Mòvé Çéll···!!]", + "tool.removeCell.displayName": "[!!Rémòvé Çéll···!!]", + "tool.removeParameter.displayName": "[!!Rémòvé Pàràmétér···!!]", + "tool.runAll.displayName": "[!!Rùñ Àll Çéllš···!!]", + "tool.runCell.displayName": "[!!Rùñ Çéll···!!]", + "tool.switchLayout.displayName": "[!!Šwïtçh Làÿòùt···!!]", + "tool.updateCell.displayName": "[!!Ùpdàté Çéll···!!]", + "tool.updateCellProperty.displayName": "[!!Ùpdàté Çéll Pròpértÿ···!!]", + "tool.updateParameter.displayName": "[!!Ùpdàté Pàràmétér···!!]" +} diff --git a/vscode/src/blazor/blazorBridge.ts b/vscode/src/blazor/blazorBridge.ts index 2df4d619..8684d092 100644 --- a/vscode/src/blazor/blazorBridge.ts +++ b/vscode/src/blazor/blazorBridge.ts @@ -147,7 +147,10 @@ export class BlazorBridge implements vscode.Disposable { this.handleFileDownload(params).catch((err) => { log.error(`file/download error: ${err instanceof Error ? err.message : String(err)}`); vscode.window.showErrorMessage( - `Export failed: ${err instanceof Error ? err.message : String(err)}` + vscode.l10n.t( + "Export failed: {0}", + err instanceof Error ? err.message : String(err) + ) ); }); }); @@ -161,7 +164,10 @@ export class BlazorBridge implements vscode.Disposable { this.handleInputRequest(params).catch((err) => { log.error(`input/request error: ${err instanceof Error ? err.message : String(err)}`); vscode.window.showErrorMessage( - `Input request failed: ${err instanceof Error ? err.message : String(err)}` + vscode.l10n.t( + "Input request failed: {0}", + err instanceof Error ? err.message : String(err) + ) ); }); }); @@ -334,8 +340,16 @@ export class BlazorBridge implements vscode.Disposable { // host reads the file directly from disk. A cancelled dialog returns a null path. const picked = await vscode.window.showOpenDialog({ canSelectMany: false, - openLabel: "Install Extension", - filters: { "Extensions": ["dll", "nupkg"] }, + openLabel: vscode.l10n.t({ + message: "Install Extension", + comment: ["The button that accepts the chosen file, in place of \"Open\"."], + }), + filters: { + [vscode.l10n.t({ + message: "Extensions", + comment: ["Names the kind of file the box will accept."], + })]: ["dll", "nupkg"], + }, }); result = { path: picked?.[0]?.fsPath ?? null }; } else if (method === "diff/sources") { @@ -464,7 +478,9 @@ export class BlazorBridge implements vscode.Disposable { const bytes = Buffer.from(p.data, "base64"); await vscode.workspace.fs.writeFile(uri, bytes); - vscode.window.showInformationMessage(`Exported to ${uri.fsPath}`); + vscode.window.showInformationMessage( + vscode.l10n.t("Exported to {0}", uri.fsPath) + ); return true; } @@ -493,7 +509,14 @@ export class BlazorBridge implements vscode.Disposable { } const value = await vscode.window.showInputBox({ - prompt: p.prompt || "Notebook input", + prompt: + p.prompt || + vscode.l10n.t({ + message: "Notebook input", + comment: [ + "Asked when a running cell wants something typed and did not say what.", + ], + }), password: !!p.isPassword, ignoreFocusOut: true, }); @@ -514,31 +537,50 @@ export class BlazorBridge implements vscode.Disposable { fileName?: string ): Record { const ext = fileName?.split(".").pop()?.toLowerCase(); + // A format's name is the same word in every language, so the kinds below are named + // by dropping it into a translated phrase rather than by translating each pairing. + const named = (format: string) => + vscode.l10n.t({ + message: "{0} Files", + args: [format], + comment: [ + "Names the kind of file a save box will accept. {0} is a format name such as CSV or HTML and is the same word in every language.", + ], + }); + const images = vscode.l10n.t({ + message: "Images", + comment: ["Names the kind of file a save box will accept: pictures."], + }); + const all = vscode.l10n.t({ + message: "All Files", + comment: ["The entry in a save box that accepts any file at all."], + }); switch (contentType) { case "text/csv": - return { "CSV Files": ["csv"], "All Files": ["*"] }; + return { [named("CSV")]: ["csv"], [all]: ["*"] }; case "application/json": - return { "JSON Files": ["json"], "All Files": ["*"] }; + return { [named("JSON")]: ["json"], [all]: ["*"] }; case "text/html": - return { "HTML Files": ["html", "htm"], "All Files": ["*"] }; + return { [named("HTML")]: ["html", "htm"], [all]: ["*"] }; case "text/markdown": - return { "Markdown Files": ["md"], "All Files": ["*"] }; + return { [named("Markdown")]: ["md"], [all]: ["*"] }; case "image/png": - return { Images: ["png"], "All Files": ["*"] }; + return { [images]: ["png"], [all]: ["*"] }; case "image/jpeg": - return { Images: ["jpg", "jpeg"], "All Files": ["*"] }; + return { [images]: ["jpg", "jpeg"], [all]: ["*"] }; case "image/svg+xml": - return { Images: ["svg"], "All Files": ["*"] }; + return { [images]: ["svg"], [all]: ["*"] }; case "image/webp": - return { Images: ["webp"], "All Files": ["*"] }; + return { [images]: ["webp"], [all]: ["*"] }; default: if (ext === "verso") { - return { "Verso Notebooks": ["verso"], "All Files": ["*"] }; + // "Verso Notebooks" is the product's name, so it reads the same everywhere. + return { "Verso Notebooks": ["verso"], [all]: ["*"] }; } if (ext) { - return { Files: [ext], "All Files": ["*"] }; + return { [named(ext.toUpperCase())]: [ext], [all]: ["*"] }; } - return { "All Files": ["*"] }; + return { [all]: ["*"] }; } } @@ -551,7 +593,15 @@ export class BlazorBridge implements vscode.Disposable { this.onDidEdit?.(); } - /** Baseline sources for the Compare menu, with git entries gated on repo membership. */ + /** + * Baseline sources for the Compare menu, with git entries gated on repo membership. + * + * The labels here are for the native quick pick the "Compare Notebook with..." command + * opens, so they follow the editor's display language like the rest of its menus. The + * notebook's own Compare panel names the same four sources from its own resources, + * because that surface follows the notebook interface language instead. Both read from + * the ids below, which are what is actually compared against. + */ listDiffSources(): { sources: Array<{ id: string; @@ -564,33 +614,57 @@ export class BlazorBridge implements vscode.Disposable { const uri = this.documentUri; const hasUri = uri !== undefined; const gitAvailable = hasUri && this.gitProvider.isAvailableFor(uri); - const notInRepo = "The notebook file is not inside a git repository."; + const notInRepo = vscode.l10n.t( + "The notebook file is not inside a git repository." + ); return { sources: [ { id: "lastSaved", - label: "Last Saved", + label: vscode.l10n.t({ + message: "Last Saved", + comment: [ + "One of the things a notebook can be compared against: the copy currently on disk.", + ], + }), kind: "lastSaved", available: hasUri, - description: hasUri ? null : "The notebook has no file on disk yet.", + description: hasUri + ? null + : vscode.l10n.t("The notebook has no file on disk yet."), }, { id: "gitHead", - label: "Git: HEAD", + label: vscode.l10n.t({ + message: "Git: HEAD", + comment: [ + "One of the things a notebook can be compared against. Git and HEAD are version control terms and are not translated.", + ], + }), kind: "git", available: gitAvailable, description: gitAvailable ? null : notInRepo, }, { id: "gitRef", - label: "Git: Compare with Ref...", + label: vscode.l10n.t({ + message: "Git: Compare with Ref...", + comment: [ + "Opens a box for choosing a branch, tag, or commit. Git and Ref are version control terms and are not translated. Keep the three dots, which mean a question follows.", + ], + }), kind: "git", available: gitAvailable, description: gitAvailable ? null : notInRepo, }, { id: "file", - label: "Choose File...", + label: vscode.l10n.t({ + message: "Choose File...", + comment: [ + "Opens a box for picking another notebook to compare against. Keep the three dots, which mean a question follows.", + ], + }), kind: "file", available: true, description: null, @@ -602,47 +676,63 @@ export class BlazorBridge implements vscode.Disposable { /** * Resolves a comparison baseline's content. Pickers (ref quick pick, file dialog) run * natively here; a dismissed picker reports `cancelled` rather than an error. + * + * Names the baseline by `labelKind` and, where the name depends on what was picked, a + * `labelArg`, rather than by a finished sentence. The name is drawn in the notebook's + * Compare panel, which is written in the notebook interface language, not the editor's, + * so the words are chosen there and only the parts nothing can translate, a git ref and + * a file name, travel from here. */ private async resolveDiffBaseline( sourceId: string | undefined ): Promise< - | { content: string; filePath?: string; label: string } + | { + content: string; + filePath?: string; + labelKind: "lastSaved" | "gitHead" | "gitRef" | "file"; + labelArg?: string; + } | { cancelled: true } > { const uri = this.documentUri; switch (sourceId) { case "lastSaved": { if (!uri) { - throw new Error("The notebook has no file on disk yet."); + throw new Error(vscode.l10n.t("The notebook has no file on disk yet.")); } const bytes = await vscode.workspace.fs.readFile(uri); return { content: new TextDecoder().decode(bytes), filePath: uri.fsPath, - label: "Last Saved", + labelKind: "lastSaved", }; } case "gitHead": { if (!uri) { - throw new Error("The notebook has no file on disk yet."); + throw new Error(vscode.l10n.t("The notebook has no file on disk yet.")); } const content = await this.gitProvider.showAtRef(uri, "HEAD"); - return { content, filePath: uri.fsPath, label: "Git: HEAD" }; + return { content, filePath: uri.fsPath, labelKind: "gitHead" }; } case "gitRef": { if (!uri) { - throw new Error("The notebook has no file on disk yet."); + throw new Error(vscode.l10n.t("The notebook has no file on disk yet.")); } const typedItem = { - label: "Type a ref or commit...", + label: vscode.l10n.t({ + message: "Type a ref or commit...", + comment: [ + "The last entry in a list of branches and tags, for naming one that is not listed. Ref and commit are version control terms.", + ], + }), description: "", ref: "__typed__", }; const picked = await vscode.window.showQuickPick( [...this.gitProvider.listRefsForQuickPick(uri), typedItem], - { placeHolder: "Compare notebook with..." } + { placeHolder: vscode.l10n.t("Compare notebook with...") } ); if (!picked) { return { cancelled: true }; @@ -650,7 +740,13 @@ export class BlazorBridge implements vscode.Disposable { const ref = picked.ref === "__typed__" ? await vscode.window.showInputBox({ - prompt: "Git branch, tag, or commit SHA", + prompt: vscode.l10n.t({ + message: "Git branch, tag, or commit SHA", + comment: [ + "Says what may be typed. Every term here is a version control term and stays as written.", + ], + }), + // The default branch name, offered as an example of what to type. placeHolder: "main", }) : picked.ref; @@ -658,14 +754,27 @@ export class BlazorBridge implements vscode.Disposable { return { cancelled: true }; } const content = await this.gitProvider.showAtRef(uri, ref); - return { content, filePath: uri.fsPath, label: `Git: ${ref}` }; + return { + content, + filePath: uri.fsPath, + labelKind: "gitRef", + labelArg: ref, + }; } case "file": { const picked = await vscode.window.showOpenDialog({ canSelectMany: false, - openLabel: "Compare", - filters: { "Notebook Files": ["verso", "ipynb", "dib"] }, + openLabel: vscode.l10n.t({ + message: "Compare", + comment: ["The button that accepts the chosen file, in place of \"Open\"."], + }), + filters: { + [vscode.l10n.t({ + message: "Notebook Files", + comment: ["Names the kind of file the box will accept."], + })]: ["verso", "ipynb", "dib"], + }, }); const file = picked?.[0]; if (!file) { @@ -675,12 +784,15 @@ export class BlazorBridge implements vscode.Disposable { return { content: new TextDecoder().decode(bytes), filePath: file.fsPath, - label: file.path.split("/").pop() ?? "File", + labelKind: "file", + labelArg: file.path.split("/").pop(), }; } default: - throw new Error(`Unknown comparison source '${sourceId}'.`); + throw new Error( + vscode.l10n.t("Unknown comparison source '{0}'.", sourceId ?? "") + ); } } diff --git a/vscode/src/blazor/blazorEditorProvider.ts b/vscode/src/blazor/blazorEditorProvider.ts index afe20569..ac31044a 100644 --- a/vscode/src/blazor/blazorEditorProvider.ts +++ b/vscode/src/blazor/blazorEditorProvider.ts @@ -12,6 +12,7 @@ import { hostRegistry } from "../host/hostRegistry"; import { notebookRegistry } from "../host/notebookRegistry"; import { BlazorBridge } from "./blazorBridge"; import { log } from "../log"; +import { LANGUAGE_SETTING, resolveLanguage } from "../localization"; import { CellAddParams, CellDto, @@ -82,6 +83,19 @@ export class BlazorEditorProvider bridge.postEditorSettings(settings); } } + + // A notebook already open keeps the language it started in. Its interface is a + // WebAssembly app, whose language is fixed when it boots, and its host process + // reads the language once on the command line; changing either means starting + // over, which would throw away whatever had not been saved. Say so, because a + // setting that appears to do nothing is worse than one that waits. + if (e.affectsConfiguration(LANGUAGE_SETTING) && this.bridges.size > 0) { + vscode.window.showInformationMessage( + vscode.l10n.t( + "Verso: the notebooks already open keep their current language. Reopen them to read them in the new one." + ) + ); + } }) ); @@ -136,9 +150,10 @@ export class BlazorEditorProvider fs.mkdirSync(dir, { recursive: true }); } catch (err) { vscode.window.showErrorMessage( - `Verso: Could not create a scratch notebook: ${ - err instanceof Error ? err.message : err - }` + vscode.l10n.t( + "Verso: Could not create a scratch notebook: {0}", + err instanceof Error ? err.message : String(err) + ) ); return; } @@ -155,9 +170,10 @@ export class BlazorEditorProvider fs.writeFileSync(tempPath, ""); } catch (err) { vscode.window.showErrorMessage( - `Verso: Could not create a scratch notebook: ${ - err instanceof Error ? err.message : err - }` + vscode.l10n.t( + "Verso: Could not create a scratch notebook: {0}", + err instanceof Error ? err.message : String(err) + ) ); return; } @@ -199,7 +215,9 @@ export class BlazorEditorProvider // `dotnet ""` and mislabeling it as a runtime problem. if (!this.hostDllPath) { vscode.window.showErrorMessage( - 'Verso: Could not find Verso.Host.dll. Set "verso.hostPath" in settings to the path of your built Verso.Host.dll.' + vscode.l10n.t( + 'Verso: Could not find Verso.Host.dll. Set "verso.hostPath" in settings to the path of your built Verso.Host.dll.' + ) ); return; } @@ -286,9 +304,10 @@ export class BlazorEditorProvider bridge.notify("notebook/opened", { filePath, ...result }); } catch (err) { vscode.window.showErrorMessage( - `Verso: Failed to open notebook: ${ - err instanceof Error ? err.message : err - }` + vscode.l10n.t( + "Verso: Failed to open notebook: {0}", + err instanceof Error ? err.message : String(err) + ) ); } } @@ -411,12 +430,15 @@ export class BlazorEditorProvider ); bridge.endRestart(); bridge.notifyFaulted( - `Restart aborted: the notebook snapshot could not be captured (${ + vscode.l10n.t( + "Restart aborted: the notebook snapshot could not be captured ({0}).", err instanceof Error ? err.message : String(err) - }).` + ) ); vscode.window.showErrorMessage( - "Verso: kernel restart aborted because the notebook snapshot could not be captured. Save and reopen the file." + vscode.l10n.t( + "Verso: kernel restart aborted because the notebook snapshot could not be captured. Save and reopen the file." + ) ); return; } @@ -437,9 +459,10 @@ export class BlazorEditorProvider invalidateDotnetResolution(this.hostDllPath); bridge.endRestart(); bridge.notifyFaulted( - `Kernel restart failed: the host process did not start (${ + vscode.l10n.t( + "Kernel restart failed: the host process did not start ({0}). Close and reopen the notebook.", err instanceof Error ? err.message : String(err) - }). Close and reopen the notebook.` + ) ); // Route through the shared handler so a missing/incompatible .NET runtime // gets the same actionable "Install .NET Runtime" guidance as a fresh open. @@ -463,14 +486,16 @@ export class BlazorEditorProvider ); bridge.endRestart(); bridge.notifyFaulted( - `Kernel restart failed: the notebook did not reopen (${ + vscode.l10n.t( + "Kernel restart failed: the notebook did not reopen ({0}).", err instanceof Error ? err.message : String(err) - }).` + ) ); vscode.window.showErrorMessage( - `Verso: kernel restart failed (notebook did not reopen): ${ + vscode.l10n.t( + "Verso: kernel restart failed (notebook did not reopen): {0}. Close and reopen the notebook.", err instanceof Error ? err.message : String(err) - }. Close and reopen the notebook.` + ) ); return; } @@ -654,6 +679,8 @@ export class BlazorEditorProvider vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir(); const target = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(path.join(defaultDir, "Untitled.verso")), + // Names the kind of file the box will accept. "Verso Notebook" is the + // product's name, so the label reads the same in every language. filters: { "Verso Notebook": ["verso"] }, }); if (!target) return; @@ -758,6 +785,12 @@ export class BlazorEditorProvider const wasmRoot = this.getWasmRoot(); const version = this.getCacheBuster(); + // Read once, into the page, because WebAssembly settles its culture while it boots: + // the translations for a language are a separate download that the runtime only knows + // to fetch if it is told the language before the app starts. Assigning a culture after + // that point leaves the app running in English and eventually faults. + const language = resolveLanguage(); + const toUri = (relativePath: string) => webview.asWebviewUri(vscode.Uri.joinPath(wasmRoot, relativePath)).toString() + `?v=${version}`; @@ -1036,11 +1069,14 @@ export class BlazorEditorProvider // The real webview resource base (used by loadBootResource to remap framework fetches). var frameworkBase = '${frameworkBase}/'; var wasmVersion = '${version}'; + // Undefined leaves the app on the browser's own language. + var versoLocale = ${language ? JSON.stringify(language) : "undefined"}; document.addEventListener('DOMContentLoaded', function() { if (typeof Blazor !== 'undefined') { var status = document.getElementById('loading-status'); if (status) status.textContent = 'Starting Blazor runtime...'; Blazor.start({ + applicationCulture: versoLocale, loadBootResource: function(type, name, defaultUri, integrity) { // Remap all framework resource URIs to real webview URIs // since is a synthetic localhost URI. diff --git a/vscode/src/copilot/participant.ts b/vscode/src/copilot/participant.ts index 9b4b6235..a4a61026 100644 --- a/vscode/src/copilot/participant.ts +++ b/vscode/src/copilot/participant.ts @@ -12,6 +12,9 @@ import { const PARTICIPANT_ID = "verso.copilot.notebook"; +// Read by the model, not by anyone. It stays in English because that is what it was +// written and tested against, and translating it would change how well the model +// follows it without changing anything a reader sees. const BASE_SYSTEM_PROMPT = `You are a Verso notebook assistant integrated into GitHub Copilot Chat. Verso is an interactive notebook environment for .NET, similar in spirit to Jupyter or Polyglot Notebooks. It is NOT built on .NET Interactive: kernel extensions written for Polyglot Notebooks are never loaded, and .NET Interactive idioms do not always transfer. @@ -54,6 +57,50 @@ Guidelines: - Cell numbers are 1-based (cell 1 is the first cell). - After running cells, examine the output and help the user understand results or fix errors.`; +// ── Wording shared by the replies below ───────────────────────────── + +/** + * Said whenever a command needs a notebook and there is not one. Written once so the + * four commands and the main handler cannot drift apart, in wording or in translation. + */ +function noNotebookOpen(): string { + return vscode.l10n.t("No Verso notebook is currently open."); +} + +/** + * A count of cells, in words. + * + * Two entries rather than a "cell(s)" that no other language can copy. Which of them is + * used is decided here because whether a language has a plural form at all, and where the + * boundary falls, is a fact about that language: Japanese and Chinese have one form and + * translate both entries the same way. A language with more forms than two would need a + * real plural rule, and none of the languages Verso ships does. + */ +function cellCount(count: number): string { + return count === 1 + ? vscode.l10n.t({ + message: "{0} cell", + args: [count], + comment: ["Used when {0} is 1. Paired with the entry below."], + }) + : vscode.l10n.t({ + message: "{0} cells", + args: [count], + comment: [ + "Used for every count other than 1. A language with one form for both translates this the same as the entry above.", + ], + }); +} + +/** Said when a cell number is outside the notebook. */ +function cellNotFound(cellNumber: number, total: number): string { + return vscode.l10n.t( + "Cell {0} not found. The notebook has {1}.", + cellNumber, + cellCount(total) + ); +} + interface CellTypeInfo { id: string; displayName: string; @@ -115,7 +162,7 @@ async function handleCellsCommand( ): Promise { const ctx = await resolveNotebook(); if (!ctx) { - stream.markdown("No Verso notebook is currently open."); + stream.markdown(noNotebookOpen()); return {}; } @@ -125,18 +172,24 @@ async function handleCellsCommand( ); if (result.cells.length === 0) { - stream.markdown("The notebook is empty."); + stream.markdown(vscode.l10n.t("The notebook is empty.")); return {}; } stream.markdown( - `**${path.basename(ctx.uri.fsPath)}** - ${result.cells.length} cell(s):\n\n` + `**${path.basename(ctx.uri.fsPath)}** - ${cellCount(result.cells.length)}:\n\n` ); for (let i = 0; i < result.cells.length; i++) { const cell = result.cells[i]; const lang = cell.language ?? cell.type; - stream.markdown(`**Cell ${i + 1}** [${lang}]\n`); + stream.markdown( + `**${vscode.l10n.t({ + message: "Cell {0}", + args: [i + 1], + comment: ["A heading over one cell's code. {0} counts from 1."], + })}** [${lang}]\n` + ); stream.markdown(`\`\`\`${lang}\n${cell.source}\n\`\`\`\n\n`); } @@ -149,11 +202,11 @@ async function handleRunCommand( ): Promise { const ctx = await resolveNotebook(); if (!ctx) { - stream.markdown("No Verso notebook is currently open."); + stream.markdown(noNotebookOpen()); return {}; } - stream.progress("Running all cells..."); + stream.progress(vscode.l10n.t("Running all cells...")); const result = await ctx.host.sendRequest( "execution/runAll", { notebookId: ctx.notebookId } @@ -164,10 +217,24 @@ async function handleRunCommand( ctx.bridge.markDirty(); for (const r of result.results) { - const status = r.status === "completed" ? "completed" : `**${r.status}**`; - stream.markdown(`Cell (${r.elapsedMs}ms): ${status}\n`); + // A status other than "completed" is drawn in bold so a failed run stands out in a + // list of them. The status word itself comes from the host, in the host's language. + const status = r.status === "completed" ? r.status : `**${r.status}**`; + stream.markdown( + `${vscode.l10n.t({ + message: "Cell ({0}ms)", + args: [r.elapsedMs], + comment: ["{0} is a number of milliseconds; ms is the unit and stays as written."], + })}: ${status}\n` + ); if (r.errorMessage) { - stream.markdown(`> Error: ${r.errorMessage}\n`); + stream.markdown( + `> ${vscode.l10n.t({ + message: "Error: {0}", + args: [r.errorMessage], + comment: ["{0} is what the cell reported, in the language the kernel reported it."], + })}\n` + ); } } @@ -180,7 +247,7 @@ async function handleVarsCommand( ): Promise { const ctx = await resolveNotebook(); if (!ctx) { - stream.markdown("No Verso notebook is currently open."); + stream.markdown(noNotebookOpen()); return {}; } @@ -190,11 +257,24 @@ async function handleVarsCommand( ); if (result.variables.length === 0) { - stream.markdown("No variables in scope. Run some cells first."); + stream.markdown( + vscode.l10n.t("No variables in scope. Run some cells first.") + ); return {}; } - stream.markdown("| Name | Type | Value |\n|---|---|---|\n"); + stream.markdown( + `| ${vscode.l10n.t({ + message: "Name", + comment: ["A table heading: what a variable is called."], + })} | ${vscode.l10n.t({ + message: "Type", + comment: ["A table heading: what kind of value a variable holds."], + })} | ${vscode.l10n.t({ + message: "Value", + comment: ["A table heading: what a variable currently holds."], + })} |\n|---|---|---|\n` + ); for (const v of result.variables) { stream.markdown(`| \`${v.name}\` | ${v.typeName} | ${v.valuePreview} |\n`); } @@ -209,14 +289,19 @@ async function handlePropsCommand( ): Promise { const ctx = await resolveNotebook(); if (!ctx) { - stream.markdown("No Verso notebook is currently open."); + stream.markdown(noNotebookOpen()); return {}; } const cellNumber = parseInt(prompt.trim(), 10); if (isNaN(cellNumber) || cellNumber < 1) { stream.markdown( - "Usage: `/props ` (1-based). Example: `/props 2`" + vscode.l10n.t({ + message: "Usage: `/props ` (1-based). Example: `/props 2`", + comment: [ + "Shown when /props was typed without a cell number. Everything in backticks is typed and stays as written.", + ], + }) ); return {}; } @@ -227,9 +312,7 @@ async function handlePropsCommand( ); const cell = cellsResult.cells[cellNumber - 1]; if (!cell) { - stream.markdown( - `Cell ${cellNumber} not found. The notebook has ${cellsResult.cells.length} cell(s).` - ); + stream.markdown(cellNotFound(cellNumber, cellsResult.cells.length)); return {}; } @@ -240,13 +323,15 @@ async function handlePropsCommand( if (result.sections.length === 0) { stream.markdown( - `Cell ${cellNumber} has no configurable properties.` + vscode.l10n.t("Cell {0} has no configurable properties.", cellNumber) ); return {}; } const lang = cell.language ?? cell.type; - stream.markdown(`**Cell ${cellNumber}** [${lang}] properties:\n\n`); + stream.markdown( + vscode.l10n.t("**Cell {0}** [{1}] properties:", cellNumber, lang) + "\n\n" + ); for (const s of result.sections) { stream.markdown(`### ${s.section.title}\n`); @@ -254,15 +339,49 @@ async function handlePropsCommand( stream.markdown(`${s.section.description}\n`); } stream.markdown( - "| Property | Type | Value | Read-only |\n|---|---|---|---|\n" + `| ${vscode.l10n.t({ + message: "Property", + comment: ["A table heading: the name of one setting on a cell."], + })} | ${vscode.l10n.t({ + message: "Type", + comment: ["A table heading: what kind of value a setting takes."], + })} | ${vscode.l10n.t({ + message: "Value", + comment: ["A table heading: what a setting is currently set to."], + })} | ${vscode.l10n.t({ + message: "Read-only", + comment: ["A table heading: whether a setting can be changed."], + })} |\n|---|---|---|---|\n` ); for (const field of s.section.fields) { - const value = field.currentValue ?? "(not set)"; + const value = + field.currentValue ?? + vscode.l10n.t({ + message: "(not set)", + comment: ["Stands in a table cell for a setting that has no value yet."], + }); + const readOnly = field.isReadOnly + ? vscode.l10n.t({ + message: "Yes", + comment: ["A table cell answering whether a setting is read-only."], + }) + : vscode.l10n.t({ + message: "No", + comment: [ + "A table cell answering whether a setting is read-only. The answer to a question, not the word for a number.", + ], + }); stream.markdown( - `| ${field.displayName} | ${field.fieldType} | ${value} | ${field.isReadOnly ? "Yes" : "No"} |\n` + `| ${field.displayName} | ${field.fieldType} | ${value} | ${readOnly} |\n` ); } - stream.markdown(`\n*Provider: \`${s.providerExtensionId}\`*\n\n`); + stream.markdown( + `\n*${vscode.l10n.t({ + message: "Provider: {0}", + args: ["`" + s.providerExtensionId + "`"], + comment: ["Names the extension a group of settings came from. {0} is its id."], + })}*\n\n` + ); } return {}; @@ -293,7 +412,9 @@ const handler: vscode.ChatRequestHandler = async ( // Check if any notebook is open if (hostRegistry.size === 0) { stream.markdown( - "No Verso notebook is currently open. Open a `.verso`, `.ipynb`, `.md`, or `.dib` file first." + vscode.l10n.t( + "No Verso notebook is currently open. Open a `.verso`, `.ipynb`, `.md`, or `.dib` file first." + ) ); return {}; } @@ -301,7 +422,7 @@ const handler: vscode.ChatRequestHandler = async ( // Resolve the active notebook and build an enriched system prompt const ctx = await resolveNotebook(); if (!ctx) { - stream.markdown("No Verso notebook is currently open."); + stream.markdown(noNotebookOpen()); return {}; } @@ -352,7 +473,9 @@ const handler: vscode.ChatRequestHandler = async ( ); } catch (err) { if (err instanceof vscode.LanguageModelError) { - stream.markdown(`Model error: ${err.message}`); + // The message is the model service's own, and arrives in whatever language + // that service answered in. + stream.markdown(vscode.l10n.t("Model error: {0}", err.message)); } return {}; } @@ -402,6 +525,8 @@ const handler: vscode.ChatRequestHandler = async ( ); } catch (err) { const message = err instanceof Error ? err.message : String(err); + // Read by the model, which decides whether to retry or explain, so this one + // stays in English along with the rest of what the tools hand back. toolResults.push( new vscode.LanguageModelToolResultPart(call.callId, [ new vscode.LanguageModelTextPart(`Tool error: ${message}`), diff --git a/vscode/src/copilot/tools.ts b/vscode/src/copilot/tools.ts index f44fb2f6..4dd67752 100644 --- a/vscode/src/copilot/tools.ts +++ b/vscode/src/copilot/tools.ts @@ -53,7 +53,11 @@ export async function resolveNotebook(): Promise { }; }); const picked = await vscode.window.showQuickPick(items, { - placeHolder: "Select a notebook for @verso", + // @verso is how the participant is addressed, so it is typed, not translated. + placeHolder: vscode.l10n.t({ + message: "Select a notebook for @verso", + comment: ["@verso is typed to address the assistant and stays as written."], + }), }); if (!picked) { return undefined; @@ -136,6 +140,15 @@ function resolveCell( return cells[cellNumber - 1]; } +/** + * Wraps a tool's answer for the model. + * + * What goes through here is read by the model, not by anyone, and it stays in English. + * The model reasons over these answers and quotes them back in whatever language the + * conversation is in, so translating them would leave one turn of a conversation written + * in two languages while gaining nothing a reader would ever see. What a reader does see + * while a tool runs, its name and the line describing the call, is translated. + */ function textResult(text: string): vscode.LanguageModelToolResult { return new vscode.LanguageModelToolResult([ new vscode.LanguageModelTextPart(text), @@ -188,8 +201,25 @@ export class AddCellTool ) { const lang = options.input.language; const lines = options.input.source.split("\n").length; + // Two entries rather than a "line(s)" no other language can copy. See the note on + // cellCount in participant.ts for why the choice is made here and not by the + // translation. + const counted = + lines === 1 + ? vscode.l10n.t({ + message: "{0} line", + args: [lines], + comment: ["Used when {0} is 1. Paired with the entry below."], + }) + : vscode.l10n.t({ + message: "{0} lines", + args: [lines], + comment: [ + "Used for every count other than 1. A language with one form for both translates this the same as the entry above.", + ], + }); return { - invocationMessage: `Adding ${lang} cell (${lines} line${lines === 1 ? "" : "s"})`, + invocationMessage: vscode.l10n.t("Adding {0} cell ({1})", lang, counted), }; } @@ -249,7 +279,10 @@ export class UpdateCellTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Updating cell ${options.input.cellNumber}`, + invocationMessage: vscode.l10n.t( + "Updating cell {0}", + options.input.cellNumber + ), }; } @@ -295,11 +328,17 @@ export class RemoveCellTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Removing cell ${options.input.cellNumber}`, + invocationMessage: vscode.l10n.t( + "Removing cell {0}", + options.input.cellNumber + ), confirmationMessages: { - title: "Remove cell", + title: vscode.l10n.t("Remove cell"), message: new vscode.MarkdownString( - `Remove cell **${options.input.cellNumber}** from the notebook?` + vscode.l10n.t( + "Remove cell **{0}** from the notebook?", + options.input.cellNumber + ) ), }, }; @@ -345,7 +384,10 @@ export class RunCellTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Running cell ${options.input.cellNumber}`, + invocationMessage: vscode.l10n.t( + "Running cell {0}", + options.input.cellNumber + ), }; } @@ -401,7 +443,7 @@ export class RunAllTool _token: vscode.CancellationToken ) { return { - invocationMessage: "Running all cells", + invocationMessage: vscode.l10n.t("Running all cells"), }; } @@ -584,7 +626,11 @@ export class AddParameterTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Adding parameter "${options.input.name}" (${options.input.type})`, + invocationMessage: vscode.l10n.t( + 'Adding parameter "{0}" ({1})', + options.input.name, + options.input.type + ), }; } @@ -643,7 +689,10 @@ export class UpdateParameterTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Updating parameter "${options.input.name}"`, + invocationMessage: vscode.l10n.t( + 'Updating parameter "{0}"', + options.input.name + ), }; } @@ -697,11 +746,17 @@ export class RemoveParameterTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Removing parameter "${options.input.name}"`, + invocationMessage: vscode.l10n.t( + 'Removing parameter "{0}"', + options.input.name + ), confirmationMessages: { - title: "Remove parameter", + title: vscode.l10n.t("Remove parameter"), message: new vscode.MarkdownString( - `Remove parameter **${options.input.name}** from the notebook?` + vscode.l10n.t( + "Remove parameter **{0}** from the notebook?", + options.input.name + ) ), }, }; @@ -809,7 +864,11 @@ export class UpdateCellPropertyTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Setting "${options.input.propertyName}" on cell ${options.input.cellNumber}`, + invocationMessage: vscode.l10n.t( + 'Setting "{0}" on cell {1}', + options.input.propertyName, + options.input.cellNumber + ), }; } @@ -898,7 +957,10 @@ export class SwitchLayoutTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Switching layout to "${options.input.layoutId}"`, + invocationMessage: vscode.l10n.t( + 'Switching layout to "{0}"', + options.input.layoutId + ), }; } @@ -972,7 +1034,11 @@ export class MoveCellTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Moving cell ${options.input.cellNumber} to position ${options.input.toPosition}`, + invocationMessage: vscode.l10n.t( + "Moving cell {0} to position {1}", + options.input.cellNumber, + options.input.toPosition + ), }; } @@ -1024,7 +1090,11 @@ export class ChangeCellTypeTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Changing cell ${options.input.cellNumber} to type "${options.input.type}"`, + invocationMessage: vscode.l10n.t( + 'Changing cell {0} to type "{1}"', + options.input.cellNumber, + options.input.type + ), }; } @@ -1082,7 +1152,11 @@ export class ChangeCellLanguageTool _token: vscode.CancellationToken ) { return { - invocationMessage: `Changing cell ${options.input.cellNumber} language to "${options.input.language}"`, + invocationMessage: vscode.l10n.t( + 'Changing cell {0} language to "{1}"', + options.input.cellNumber, + options.input.language + ), }; } diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 1c24fb8c..c4393220 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -23,9 +23,13 @@ export async function activate( if (hostDllPath) { log.info(`Resolved Verso.Host.dll: ${hostDllPath}`); } else { + // The log line stays in English: it is read alongside stack traces and is what + // somebody searches for when they report a problem. log.error('Could not find Verso.Host.dll. Set "verso.hostPath" in settings to the path of your built Verso.Host.dll.'); vscode.window.showErrorMessage( - 'Verso: Could not find Verso.Host.dll. Set "verso.hostPath" in settings to the path of your built Verso.Host.dll.' + vscode.l10n.t( + 'Verso: Could not find Verso.Host.dll. Set "verso.hostPath" in settings to the path of your built Verso.Host.dll.' + ) ); } @@ -80,7 +84,7 @@ export async function activate( const notebook = await resolveNotebook(); if (!notebook) { vscode.window.showInformationMessage( - "Verso: Open a notebook to compare it with a baseline." + vscode.l10n.t("Verso: Open a notebook to compare it with a baseline.") ); return; } @@ -89,18 +93,28 @@ export async function activate( const picked = await vscode.window.showQuickPick( sources.map((s) => ({ label: s.label, - description: s.available ? "" : s.description ?? "unavailable", + description: s.available + ? "" + : s.description ?? vscode.l10n.t({ + message: "unavailable", + comment: [ + "Said of a baseline that cannot be compared against, for a reason nothing here knows.", + ], + }), sourceId: s.id, available: s.available, })), - { placeHolder: "Compare notebook with..." } + { placeHolder: vscode.l10n.t("Compare notebook with...") } ); if (!picked) { return; } if (!picked.available) { vscode.window.showInformationMessage( - `Verso: ${picked.description || "This comparison source is not available."}` + `Verso: ${ + picked.description || + vscode.l10n.t("This comparison source is not available.") + }` ); return; } diff --git a/vscode/src/git/gitBaselineProvider.ts b/vscode/src/git/gitBaselineProvider.ts index 9aa495d6..4ba9b277 100644 --- a/vscode/src/git/gitBaselineProvider.ts +++ b/vscode/src/git/gitBaselineProvider.ts @@ -49,7 +49,9 @@ export class GitBaselineProvider { async showAtRef(uri: vscode.Uri, ref: string): Promise { const repo = this.getRepo(uri); if (!repo) { - throw new Error("This notebook is not inside a git repository."); + throw new Error( + vscode.l10n.t("This notebook is not inside a git repository.") + ); } try { return await repo.show(ref, uri.fsPath); @@ -71,16 +73,51 @@ export class GitBaselineProvider { } const describe = (kind: string) => (r: Ref) => ({ - label: r.name ?? r.commit ?? "(unnamed)", + label: + r.name ?? + r.commit ?? + vscode.l10n.t({ + message: "(unnamed)", + comment: ["Stands in for a branch or tag that has no name to show."], + }), description: `${kind}${r.commit ? ` ${r.commit.substring(0, 8)}` : ""}`, ref: r.name ?? r.commit ?? "", }); const refs = repo.state.refs; return [ - ...refs.filter((r) => r.type === RefType.Head && r.name).map(describe("branch")), - ...refs.filter((r) => r.type === RefType.RemoteHead && r.name).map(describe("remote branch")), - ...refs.filter((r) => r.type === RefType.Tag && r.name).map(describe("tag")), + ...refs + .filter((r) => r.type === RefType.Head && r.name) + .map( + describe( + vscode.l10n.t({ + message: "branch", + comment: [ + "Says what kind of thing is listed, shown beside its name. A line of work in version control.", + ], + }) + ) + ), + ...refs + .filter((r) => r.type === RefType.RemoteHead && r.name) + .map( + describe( + vscode.l10n.t({ + message: "remote branch", + comment: ["A branch that lives on the server rather than on this machine."], + }) + ) + ), + ...refs + .filter((r) => r.type === RefType.Tag && r.name) + .map( + describe( + vscode.l10n.t({ + message: "tag", + comment: ["A name pinned to one point in a project's history."], + }) + ) + ), ].filter((item) => item.ref.length > 0); } @@ -99,17 +136,27 @@ export class GitBaselineProvider { // repository's ENTIRE file listing appended. Never surface that raw message. raw.includes("relative path not found") ) { - return `'${fileName}' is not tracked at '${ref}'. Commit the file first, or pick a different ref.`; + return vscode.l10n.t( + "'{0}' is not tracked at '{1}'. Commit the file first, or pick a different ref.", + fileName, + ref + ); } if (raw.includes("unknown revision") || raw.includes("invalid object name")) { - return `'${ref}' is not a known branch, tag, or commit.`; + return vscode.l10n.t("'{0}' is not a known branch, tag, or commit.", ref); } // Fallback: keep only the first line, capped, so an unexpected git failure stays a - // sentence rather than a wall of output. + // sentence rather than a wall of output. The detail itself is whatever git said, + // which git says in its own language and this cannot translate. const firstLine = raw.split("\n", 1)[0] ?? ""; const detail = firstLine.length > 200 ? `${firstLine.substring(0, 200)}...` : firstLine; - return `git could not read '${fileName}' at '${ref}': ${detail}`; + return vscode.l10n.t( + "git could not read '{0}' at '{1}': {2}", + fileName, + ref, + detail + ); } } diff --git a/vscode/src/host/dotnetRuntime.ts b/vscode/src/host/dotnetRuntime.ts index 6c3c02f4..0ca13eae 100644 --- a/vscode/src/host/dotnetRuntime.ts +++ b/vscode/src/host/dotnetRuntime.ts @@ -156,7 +156,7 @@ export async function showHostStartError( } vscode.window.showErrorMessage( - `Verso: Failed to start host process: ${describeError(err)}` + vscode.l10n.t("Verso: Failed to start host process: {0}", describeError(err)) ); } @@ -171,19 +171,28 @@ async function showDotnetSetupError( lastSetupPromptAt = now; const version = getRequiredRuntimeVersion(hostDllPath); - const message = - `Verso needs the .NET runtime (version ${version} or later) to run ` + - `notebooks, but a compatible installation was not found.`; + const message = vscode.l10n.t( + "Verso needs the .NET runtime (version {0} or later) to run notebooks, but a compatible installation was not found.", + version + ); - const install = "Install .NET Runtime"; - const help = "Setup Help"; + const install = vscode.l10n.t({ + message: "Install .NET Runtime", + comment: ["A button. .NET is a product name and stays as written."], + }); + const help = vscode.l10n.t({ + message: "Setup Help", + comment: ["A button. It opens the page describing how to set Verso up."], + }); const choice = await vscode.window.showErrorMessage(message, install, help); if (choice === install) { const installed = await attemptRuntimeAcquisition(context, hostDllPath); if (installed) { vscode.window.showInformationMessage( - "Verso: the .NET runtime is installed. Reopen the notebook to continue." + vscode.l10n.t( + "Verso: the .NET runtime is installed. Reopen the notebook to continue." + ) ); } else { // On-demand install did not complete (offline, blocked, or declined); @@ -223,7 +232,7 @@ async function attemptRuntimeAcquisition( const acquired = await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: "Verso: installing the .NET runtime...", + title: vscode.l10n.t("Verso: installing the .NET runtime..."), }, () => vscode.commands.executeCommand(INSTALL_TOOL_ACQUIRE, { diff --git a/vscode/src/host/hostProcess.ts b/vscode/src/host/hostProcess.ts index 9970d354..748327ab 100644 --- a/vscode/src/host/hostProcess.ts +++ b/vscode/src/host/hostProcess.ts @@ -7,6 +7,7 @@ import { JsonRpcNotification, } from "./protocol"; import { log } from "../log"; +import { resolveLanguage } from "../localization"; type NotificationHandler = (params: unknown) => void; @@ -69,6 +70,22 @@ function buildHostEnvironment(): NodeJS.ProcessEnv { return env; } +/** + * The command line the host is started with. The language travels here rather than in the + * environment because the environment is inherited by the tools the host launches in turn, + * and a Python interpreter has no business being told what language the notebook chrome is in. + */ +function buildHostArguments(hostDllPath: string): string[] { + const args = [hostDllPath]; + + const language = resolveLanguage(); + if (language) { + args.push("--language", language); + } + + return args; +} + export class HostProcess implements vscode.Disposable { private process: ChildProcess | undefined; private readline: ReadlineInterface | undefined; @@ -133,8 +150,9 @@ export class HostProcess implements vscode.Disposable { 30000 ); - log.info(`Spawning Verso.Host: ${this.dotnetCommand} ${this.hostDllPath}`); - this.process = spawn(this.dotnetCommand, [this.hostDllPath], { + const hostArgs = buildHostArguments(this.hostDllPath); + log.info(`Spawning Verso.Host: ${this.dotnetCommand} ${hostArgs.join(" ")}`); + this.process = spawn(this.dotnetCommand, hostArgs, { stdio: ["pipe", "pipe", "pipe"], env: buildHostEnvironment(), }); @@ -172,8 +190,10 @@ export class HostProcess implements vscode.Disposable { `for the faulting stack; on Linux, see your distro's coredump location.` ); } + // The detail is a signal name or an exit code, which reads the same + // whatever language the sentence around it is in. vscode.window.showWarningMessage( - `Verso host process exited (${detail.toast})` + vscode.l10n.t("Verso host process exited ({0})", detail.toast) ); this.onUnexpectedExit?.(detail.toast); } else { diff --git a/vscode/src/localization.ts b/vscode/src/localization.ts new file mode 100644 index 00000000..be47a958 --- /dev/null +++ b/vscode/src/localization.ts @@ -0,0 +1,77 @@ +import * as vscode from "vscode"; + +/** + * The setting that names the notebook interface language. Exported so a + * configuration-change listener can test for it without repeating the string. + */ +export const LANGUAGE_SETTING = "verso.language"; + +/** + * Language tags Verso ships an interface in, mirroring the .NET side. Kept here rather + * than asked for at runtime because the language has to be decided before anything .NET + * starts: it is passed to the host process on its command line and to the notebook app + * as a boot parameter. + */ +const SHIPPED = ["en", "de", "es", "ja", "zh-Hans"]; + +/** + * VS Code display-language identifiers that are not the language tag .NET expects. + * There are only a handful, and the Chinese pair is the reason this table exists at all: + * `zh-cn` has to become `zh-Hans` because .NET names Chinese by script, and `zh-tw` maps + * to a script Verso does not ship, so it deliberately finds no translation rather than + * being served simplified characters. + */ +const DISPLAY_LANGUAGES: Record = { + "zh-cn": "zh-Hans", + "zh-tw": "zh-Hant", +}; + +/** + * Narrows a language tag onto a shipped language, dropping the region when there is no + * regional translation, so `de-AT` finds German. + * + * @returns The shipped tag, or undefined when the language is not one of them. + */ +function match(tag: string | undefined): string | undefined { + if (!tag) { + return undefined; + } + + const normalized = DISPLAY_LANGUAGES[tag.toLowerCase()] ?? tag; + const candidates = [normalized, normalized.split("-")[0]]; + + for (const candidate of candidates) { + const shipped = SHIPPED.find( + (s) => s.toLowerCase() === candidate.toLowerCase() + ); + if (shipped) { + return shipped; + } + } + + return undefined; +} + +/** + * The language to run the notebook interface and the host process in. + * + * An explicit setting is passed through as written, even when it is not a shipped + * language, so that a hand-set value reaches .NET and is either understood there or + * falls back there. Left on auto, the VS Code display language is used when Verso has + * that language and nothing is returned when it does not, which leaves the .NET side + * free to answer from the environment or the operating system instead. + * + * @returns A language tag, or undefined to let the .NET side decide. + */ +export function resolveLanguage(): string | undefined { + const setting = vscode.workspace + .getConfiguration("verso") + .get("language") + ?.trim(); + + if (setting && setting !== "auto") { + return setting; + } + + return match(vscode.env.language); +} diff --git a/vscode/test/suite/blazorBridge.diff.test.ts b/vscode/test/suite/blazorBridge.diff.test.ts index c7535a3e..a402c14e 100644 --- a/vscode/test/suite/blazorBridge.diff.test.ts +++ b/vscode/test/suite/blazorBridge.diff.test.ts @@ -86,6 +86,25 @@ suite("BlazorBridge diff endpoints", () => { assert.strictEqual(byId.get("file")?.available, true); }); + test("diff source ids are the same in every language", () => { + const { webview } = createFakeWebview(); + const bridge = new BlazorBridge(webview, createFakeHost()); + + // Labels are written in the editor's display language and the notebook writes its + // own from these ids, so an id that moved with the language would break both: the + // notebook could not name the source, and diff/baseline could not resolve it. + assert.deepStrictEqual( + bridge.listDiffSources().sources.map((s) => s.id), + ["lastSaved", "gitHead", "gitRef", "file"] + ); + for (const source of bridge.listDiffSources().sources) { + assert.ok( + source.label.length > 0, + `${source.id} reached the picker with no label` + ); + } + }); + test("diff/sources and diff/baseline never mark the document dirty", async () => { const { webview, emit } = createFakeWebview(); const bridge = new BlazorBridge(webview, createFakeHost()); @@ -171,11 +190,16 @@ suite("BlazorBridge diff endpoints", () => { const baseline = result as { content: string; filePath?: string; - label: string; + labelKind: string; + labelArg?: string; }; assert.strictEqual(baseline.content, payload); - assert.strictEqual(baseline.label, "Last Saved"); assert.strictEqual(baseline.filePath, tempFile.fsPath); + // Says which baseline this is rather than what to call it. The notebook writes + // the name itself, in the notebook interface language, which the reader may have + // set differently from the editor's. + assert.strictEqual(baseline.labelKind, "lastSaved"); + assert.strictEqual(baseline.labelArg, undefined); } finally { await vscode.workspace.fs.delete(tempFile); } From 4dfa7ffd112785913494f46e489e3eb59b5cb276 Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sun, 2 Aug 2026 10:48:03 -0400 Subject: [PATCH 2/4] Add i18n tooling and localization resources Introduce translation tooling and many localization assets: add build/i18n scripts (export.py, merge.py) and update i18n README and CONTRIBUTING with translation workflow and guidelines; ignore working translation files in .gitignore. Add plural/theming/localized text helpers and CellText resources across projects, plus numerous .resx and VS Code l10n JSON files for de/es/ja/zh and pseudo locales. Wire resources into projects, add tests for theming and localization, and remove the old ThemeCssGenerator. This enables exporting, merging and managing translations in passes and centralizes localization handling across assemblies. Signed-off-by: Torrey Betts --- .gitignore | 4 + CONTRIBUTING.md | 30 + build/i18n/README.md | 146 ++- build/i18n/export.py | 121 ++ build/i18n/glossary.md | 202 ++- build/i18n/merge.py | 115 ++ build/i18n/resources.py | 25 +- build/i18n/translate.py | 6 +- src/Verso.Abstractions/Localization/Plural.cs | 29 + src/Verso.Abstractions/Theming/ThemeCss.cs | 143 +++ src/Verso.Ado/CellType/SqlCellRenderer.cs | 3 +- src/Verso.Ado/CellType/SqlCellType.cs | 3 +- .../Formatters/ResultSetFormatter.cs | 49 +- src/Verso.Ado/Import/JupyterSqlImportHook.cs | 3 +- src/Verso.Ado/Kernel/SqlKernel.cs | 97 +- src/Verso.Ado/Localization/CellText.cs | 20 + .../MagicCommands/SqlConnectMagicCommand.cs | 43 +- .../SqlDisconnectMagicCommand.cs | 12 +- .../MagicCommands/SqlScaffoldMagicCommand.cs | 45 +- .../MagicCommands/SqlSchemaMagicCommand.cs | 48 +- src/Verso.Ado/Models/SqlDirectives.cs | 4 +- src/Verso.Ado/Resources/Strings.de.resx | 430 +++++++ src/Verso.Ado/Resources/Strings.es.resx | 430 +++++++ src/Verso.Ado/Resources/Strings.ja.resx | 430 +++++++ src/Verso.Ado/Resources/Strings.qps-Ploc.resx | 430 +++++++ src/Verso.Ado/Resources/Strings.resx | 549 ++++++++ src/Verso.Ado/Resources/Strings.zh-Hans.resx | 430 +++++++ src/Verso.Ado/SqlAdoExtension.cs | 3 +- .../ToolbarActions/ExportCsvAction.cs | 3 +- .../ToolbarActions/ExportJsonAction.cs | 3 +- src/Verso.Ado/Verso.Ado.csproj | 17 + .../Notebook/PropertyFieldComponent.razor | 11 +- .../Components/Notebook/SettingsPanel.razor | 16 +- .../Components/ThemeProvider.razor | 97 +- src/Verso.Blazor.Shared/Resources/Plural.cs | 24 - src/Verso.Blazor.Shared/Resources/UI.de.resx | 843 +++++++++++++ src/Verso.Blazor.Shared/Resources/UI.es.resx | 922 ++++++++++++++ src/Verso.Blazor.Shared/Resources/UI.ja.resx | 922 ++++++++++++++ .../Resources/UI.zh-Hans.resx | 922 ++++++++++++++ src/Verso.Cli/Commands/ConvertCommand.cs | 42 +- src/Verso.Cli/Commands/ExportCommand.cs | 86 +- src/Verso.Cli/Commands/InfoCommand.cs | 19 +- src/Verso.Cli/Commands/ReplCommand.cs | 144 ++- src/Verso.Cli/Commands/RunCommand.cs | 62 +- src/Verso.Cli/Commands/ServeCommand.cs | 38 +- src/Verso.Cli/Execution/HeadlessRunner.cs | 36 +- src/Verso.Cli/Execution/JsonOutputWriter.cs | 8 + src/Verso.Cli/Execution/OutputRenderer.cs | 49 +- .../Execution/ToolbarActionResolver.cs | 12 +- src/Verso.Cli/Parameters/ParameterResolver.cs | 23 +- src/Verso.Cli/Program.cs | 5 +- src/Verso.Cli/Repl/Meta/Commands/ClearMeta.cs | 11 +- .../Repl/Meta/Commands/ConvertMeta.cs | 21 +- src/Verso.Cli/Repl/Meta/Commands/ExitMeta.cs | 15 +- .../Repl/Meta/Commands/ExportMeta.cs | 48 +- src/Verso.Cli/Repl/Meta/Commands/HelpMeta.cs | 28 +- .../Repl/Meta/Commands/HistoryMeta.cs | 17 +- .../Repl/Meta/Commands/KernelMeta.cs | 27 +- .../Repl/Meta/Commands/LayoutMeta.cs | 20 +- src/Verso.Cli/Repl/Meta/Commands/ListMeta.cs | 46 +- src/Verso.Cli/Repl/Meta/Commands/LoadMeta.cs | 28 +- src/Verso.Cli/Repl/Meta/Commands/MdMeta.cs | 13 +- .../Repl/Meta/Commands/RecallMeta.cs | 22 +- src/Verso.Cli/Repl/Meta/Commands/RerunMeta.cs | 34 +- src/Verso.Cli/Repl/Meta/Commands/ResetMeta.cs | 14 +- src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs | 28 +- src/Verso.Cli/Repl/Meta/Commands/SetMeta.cs | 37 +- src/Verso.Cli/Repl/Meta/Commands/ThemeMeta.cs | 22 +- src/Verso.Cli/Repl/Meta/Commands/VarsMeta.cs | 21 +- src/Verso.Cli/Repl/Meta/Commands/ViewMeta.cs | 19 +- .../Repl/Prompt/PrettyPromptDriver.cs | 4 +- .../Repl/Rendering/MimeDispatcher.cs | 14 +- .../Rendering/Renderers/CsvTableRenderer.cs | 8 +- .../Renderers/ImagePlaceholderRenderer.cs | 19 +- .../Repl/Rendering/TerminalRenderer.cs | 9 +- .../Repl/Rendering/TruncationPolicy.cs | 6 +- src/Verso.Cli/Repl/ReplLoop.cs | 69 +- src/Verso.Cli/Resources/Strings.de.resx | 853 +++++++++++++ src/Verso.Cli/Resources/Strings.es.resx | 851 +++++++++++++ src/Verso.Cli/Resources/Strings.ja.resx | 852 +++++++++++++ src/Verso.Cli/Resources/Strings.qps-Ploc.resx | 851 +++++++++++++ src/Verso.Cli/Resources/Strings.resx | 1103 +++++++++++++++++ src/Verso.Cli/Resources/Strings.zh-Hans.resx | 851 +++++++++++++ src/Verso.Cli/Utilities/CellCount.cs | 20 + src/Verso.Cli/Utilities/DisplayWidth.cs | 82 ++ src/Verso.Cli/Utilities/LanguageOption.cs | 13 +- src/Verso.Cli/Utilities/Messages.cs | 59 + .../Utilities/PythonAutoInstallOption.cs | 6 +- .../Utilities/PythonInterpreterOption.cs | 5 +- src/Verso.Cli/Utilities/SerializerResolver.cs | 9 +- src/Verso.Cli/Verso.Cli.csproj | 15 + src/Verso.FSharp/FSharpExtension.cs | 3 +- .../Formatters/FSharpDataFormatter.cs | 3 +- .../Import/JupyterFSharpPostProcessor.cs | 3 +- src/Verso.FSharp/Kernel/FSharpKernel.cs | 33 +- .../NuGet/NuGetFallbackResolver.cs | 12 +- src/Verso.FSharp/Resources/Strings.de.resx | 121 ++ src/Verso.FSharp/Resources/Strings.es.resx | 121 ++ src/Verso.FSharp/Resources/Strings.ja.resx | 121 ++ .../Resources/Strings.qps-Ploc.resx | 121 ++ src/Verso.FSharp/Resources/Strings.resx | 141 +++ .../Resources/Strings.zh-Hans.resx | 121 ++ src/Verso.FSharp/Verso.FSharp.csproj | 17 + src/Verso.Host/Handlers/DiffHandler.cs | 5 +- src/Verso.Host/Handlers/ExtensionHandler.cs | 5 +- src/Verso.Host/Handlers/LayoutHandler.cs | 14 +- src/Verso.Host/Handlers/ParameterHandler.cs | 22 +- src/Verso.Host/Resources/Strings.de.resx | 88 ++ src/Verso.Host/Resources/Strings.es.resx | 88 ++ src/Verso.Host/Resources/Strings.ja.resx | 88 ++ .../Resources/Strings.qps-Ploc.resx | 88 ++ src/Verso.Host/Resources/Strings.resx | 97 ++ src/Verso.Host/Resources/Strings.zh-Hans.resx | 88 ++ src/Verso.Host/Verso.Host.csproj | 17 + src/Verso.Http/CellType/HttpCellRenderer.cs | 3 +- src/Verso.Http/CellType/HttpCellType.cs | 3 +- .../Formatting/HttpResponseFormatter.cs | 12 +- src/Verso.Http/Kernel/HttpKernel.cs | 100 +- src/Verso.Http/Localization/CellText.cs | 17 + .../MagicCommands/HttpSetBaseMagicCommand.cs | 10 +- .../HttpSetHeaderMagicCommand.cs | 14 +- .../HttpSetTimeoutMagicCommand.cs | 10 +- src/Verso.Http/Resources/Strings.de.resx | 217 ++++ src/Verso.Http/Resources/Strings.es.resx | 217 ++++ src/Verso.Http/Resources/Strings.ja.resx | 217 ++++ .../Resources/Strings.qps-Ploc.resx | 217 ++++ src/Verso.Http/Resources/Strings.resx | 269 ++++ src/Verso.Http/Resources/Strings.zh-Hans.resx | 217 ++++ src/Verso.Http/Verso.Http.csproj | 17 + .../Kernel/IJavaScriptRunner.cs | 3 +- .../Kernel/JavaScriptKernel.cs | 22 +- src/Verso.JavaScript/Kernel/JintRunner.cs | 5 +- .../Kernel/NodeProcessRunner.cs | 7 +- .../Kernel/TypeScriptKernel.cs | 17 +- .../MagicCommands/NpmMagicCommand.cs | 19 +- .../MagicCommands/NpmManager.cs | 7 +- .../MagicCommands/NpmReport.cs | 60 +- .../Resources/Strings.de.resx | 205 +++ .../Resources/Strings.es.resx | 205 +++ .../Resources/Strings.ja.resx | 205 +++ .../Resources/Strings.qps-Ploc.resx | 205 +++ src/Verso.JavaScript/Resources/Strings.resx | 253 ++++ .../Resources/Strings.zh-Hans.resx | 205 +++ src/Verso.JavaScript/Verso.JavaScript.csproj | 17 + .../Kernel/PowerShellKernel.cs | 3 +- src/Verso.PowerShell/PowerShellExtension.cs | 3 +- .../Resources/Strings.de.resx | 67 + .../Resources/Strings.es.resx | 67 + .../Resources/Strings.ja.resx | 67 + .../Resources/Strings.qps-Ploc.resx | 67 + src/Verso.PowerShell/Resources/Strings.resx | 69 ++ .../Resources/Strings.zh-Hans.resx | 67 + src/Verso.PowerShell/Verso.PowerShell.csproj | 17 + src/Verso.Python/Kernel/PythonKernel.cs | 27 +- .../MagicCommands/PipMagicCommand.cs | 14 +- .../MagicCommands/PythonMagicCommand.cs | 44 +- .../PackageManagement/AutoInstallService.cs | 20 +- .../PackageManagement/InstallReport.cs | 41 +- .../PackageManagement/PackageInstaller.cs | 7 +- src/Verso.Python/Resources/Strings.de.resx | 213 ++++ src/Verso.Python/Resources/Strings.es.resx | 213 ++++ src/Verso.Python/Resources/Strings.ja.resx | 213 ++++ .../Resources/Strings.qps-Ploc.resx | 213 ++++ src/Verso.Python/Resources/Strings.resx | 263 ++++ .../Resources/Strings.zh-Hans.resx | 213 ++++ src/Verso.Python/Verso.Python.csproj | 17 + src/Verso/Contexts/ExecutionContext.cs | 3 +- src/Verso/Execution/BackgroundFaultMonitor.cs | 6 + src/Verso/Execution/ExecutionPipeline.cs | 3 +- src/Verso/Export/NotebookHtmlExporter.cs | 6 +- src/Verso/Export/NotebookMarkdownExporter.cs | 6 +- src/Verso/Export/ThemeCssGenerator.cs | 97 -- src/Verso/Extensions/ExtensionHost.cs | 5 +- .../Formatters/CollectionFormatter.cs | 3 +- .../Formatters/ExceptionFormatter.cs | 3 +- .../Extensions/Formatters/HtmlFormatter.cs | 3 +- .../Extensions/Formatters/ImageFormatter.cs | 3 +- .../Extensions/Formatters/ObjectFormatter.cs | 3 +- .../Formatters/ObjectTreeRenderer.cs | 16 +- .../Formatters/PrimitiveFormatter.cs | 3 +- .../Extensions/Formatters/SvgFormatter.cs | 3 +- src/Verso/Extensions/Kernels/HtmlKernel.cs | 2 +- src/Verso/Extensions/Kernels/MermaidKernel.cs | 2 +- src/Verso/Kernels/CSharpKernel.cs | 5 +- src/Verso/Kernels/NuGetPackageResolver.cs | 4 +- src/Verso/LayoutManager.cs | 3 +- src/Verso/Localization/CellText.cs | 20 + src/Verso/MagicCommands/AboutMagicCommand.cs | 11 +- .../MagicCommands/ExtensionMagicCommand.cs | 48 +- src/Verso/MagicCommands/ImportMagicCommand.cs | 50 +- src/Verso/MagicCommands/NuGetMagicCommand.cs | 17 +- .../MagicCommands/RestartMagicCommand.cs | 9 +- src/Verso/MagicCommands/TimeMagicCommand.cs | 3 +- src/Verso/Parameters/ParameterValueParser.cs | 13 +- src/Verso/Resources/Strings.de.resx | 666 ++++++++++ src/Verso/Resources/Strings.es.resx | 666 ++++++++++ src/Verso/Resources/Strings.ja.resx | 666 ++++++++++ src/Verso/Resources/Strings.qps-Ploc.resx | 290 +++++ src/Verso/Resources/Strings.resx | 386 ++++++ src/Verso/Resources/Strings.zh-Hans.resx | 666 ++++++++++ src/Verso/Scaffold.cs | 5 +- src/Verso/Serializers/DibSerializer.cs | 3 +- src/Verso/Serializers/JupyterSerializer.cs | 3 +- src/Verso/Serializers/VersoSerializer.cs | 3 +- src/Verso/ThemeEngine.cs | 3 +- .../Theming/ThemeCssTests.cs | 131 ++ .../Formatters/ResultSetFormatterTests.cs | 6 +- .../Integration/SqlIntegrationTests.cs | 2 +- .../Verso.Ado.Tests/Kernel/SqlKernelTests.cs | 6 +- .../Localization/SqlTextTests.cs | 89 ++ .../ThemeProviderTests.cs | 33 + .../Repl/MetaCommandTextTests.cs | 113 ++ .../Utilities/CellCountTests.cs | 51 + .../Utilities/DisplayWidthTests.cs | 72 ++ .../Utilities/MessagesTests.cs | 108 ++ .../Localization/KernelTextTests.cs | 73 ++ .../Localization/CoreMessageTextTests.cs | 158 +++ vscode/l10n/bundle.l10n.de.json | 96 ++ vscode/l10n/bundle.l10n.es.json | 96 ++ vscode/l10n/bundle.l10n.ja.json | 96 ++ vscode/l10n/bundle.l10n.zh-cn.json | 96 ++ vscode/package.nls.de.json | 44 + vscode/package.nls.es.json | 44 + vscode/package.nls.ja.json | 44 + vscode/package.nls.zh-cn.json | 44 + vscode/src/blazor/blazorEditorProvider.ts | 12 +- 226 files changed, 24588 insertions(+), 1174 deletions(-) create mode 100644 build/i18n/export.py create mode 100644 build/i18n/merge.py create mode 100644 src/Verso.Abstractions/Localization/Plural.cs create mode 100644 src/Verso.Abstractions/Theming/ThemeCss.cs create mode 100644 src/Verso.Ado/Localization/CellText.cs create mode 100644 src/Verso.Ado/Resources/Strings.de.resx create mode 100644 src/Verso.Ado/Resources/Strings.es.resx create mode 100644 src/Verso.Ado/Resources/Strings.ja.resx create mode 100644 src/Verso.Ado/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.Ado/Resources/Strings.resx create mode 100644 src/Verso.Ado/Resources/Strings.zh-Hans.resx delete mode 100644 src/Verso.Blazor.Shared/Resources/Plural.cs create mode 100644 src/Verso.Blazor.Shared/Resources/UI.es.resx create mode 100644 src/Verso.Blazor.Shared/Resources/UI.ja.resx create mode 100644 src/Verso.Blazor.Shared/Resources/UI.zh-Hans.resx create mode 100644 src/Verso.Cli/Resources/Strings.de.resx create mode 100644 src/Verso.Cli/Resources/Strings.es.resx create mode 100644 src/Verso.Cli/Resources/Strings.ja.resx create mode 100644 src/Verso.Cli/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.Cli/Resources/Strings.resx create mode 100644 src/Verso.Cli/Resources/Strings.zh-Hans.resx create mode 100644 src/Verso.Cli/Utilities/CellCount.cs create mode 100644 src/Verso.Cli/Utilities/DisplayWidth.cs create mode 100644 src/Verso.Cli/Utilities/Messages.cs create mode 100644 src/Verso.FSharp/Resources/Strings.de.resx create mode 100644 src/Verso.FSharp/Resources/Strings.es.resx create mode 100644 src/Verso.FSharp/Resources/Strings.ja.resx create mode 100644 src/Verso.FSharp/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.FSharp/Resources/Strings.resx create mode 100644 src/Verso.FSharp/Resources/Strings.zh-Hans.resx create mode 100644 src/Verso.Host/Resources/Strings.de.resx create mode 100644 src/Verso.Host/Resources/Strings.es.resx create mode 100644 src/Verso.Host/Resources/Strings.ja.resx create mode 100644 src/Verso.Host/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.Host/Resources/Strings.resx create mode 100644 src/Verso.Host/Resources/Strings.zh-Hans.resx create mode 100644 src/Verso.Http/Localization/CellText.cs create mode 100644 src/Verso.Http/Resources/Strings.de.resx create mode 100644 src/Verso.Http/Resources/Strings.es.resx create mode 100644 src/Verso.Http/Resources/Strings.ja.resx create mode 100644 src/Verso.Http/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.Http/Resources/Strings.resx create mode 100644 src/Verso.Http/Resources/Strings.zh-Hans.resx create mode 100644 src/Verso.JavaScript/Resources/Strings.de.resx create mode 100644 src/Verso.JavaScript/Resources/Strings.es.resx create mode 100644 src/Verso.JavaScript/Resources/Strings.ja.resx create mode 100644 src/Verso.JavaScript/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.JavaScript/Resources/Strings.resx create mode 100644 src/Verso.JavaScript/Resources/Strings.zh-Hans.resx create mode 100644 src/Verso.PowerShell/Resources/Strings.de.resx create mode 100644 src/Verso.PowerShell/Resources/Strings.es.resx create mode 100644 src/Verso.PowerShell/Resources/Strings.ja.resx create mode 100644 src/Verso.PowerShell/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.PowerShell/Resources/Strings.resx create mode 100644 src/Verso.PowerShell/Resources/Strings.zh-Hans.resx create mode 100644 src/Verso.Python/Resources/Strings.de.resx create mode 100644 src/Verso.Python/Resources/Strings.es.resx create mode 100644 src/Verso.Python/Resources/Strings.ja.resx create mode 100644 src/Verso.Python/Resources/Strings.qps-Ploc.resx create mode 100644 src/Verso.Python/Resources/Strings.resx create mode 100644 src/Verso.Python/Resources/Strings.zh-Hans.resx delete mode 100644 src/Verso/Export/ThemeCssGenerator.cs create mode 100644 src/Verso/Localization/CellText.cs create mode 100644 src/Verso/Resources/Strings.de.resx create mode 100644 src/Verso/Resources/Strings.es.resx create mode 100644 src/Verso/Resources/Strings.ja.resx create mode 100644 src/Verso/Resources/Strings.zh-Hans.resx create mode 100644 tests/Verso.Abstractions.Tests/Theming/ThemeCssTests.cs create mode 100644 tests/Verso.Ado.Tests/Localization/SqlTextTests.cs create mode 100644 tests/Verso.Cli.Tests/Repl/MetaCommandTextTests.cs create mode 100644 tests/Verso.Cli.Tests/Utilities/CellCountTests.cs create mode 100644 tests/Verso.Cli.Tests/Utilities/DisplayWidthTests.cs create mode 100644 tests/Verso.Cli.Tests/Utilities/MessagesTests.cs create mode 100644 tests/Verso.Python.Tests/Localization/KernelTextTests.cs create mode 100644 tests/Verso.Tests/Localization/CoreMessageTextTests.cs create mode 100644 vscode/l10n/bundle.l10n.de.json create mode 100644 vscode/l10n/bundle.l10n.es.json create mode 100644 vscode/l10n/bundle.l10n.ja.json create mode 100644 vscode/l10n/bundle.l10n.zh-cn.json create mode 100644 vscode/package.nls.de.json create mode 100644 vscode/package.nls.es.json create mode 100644 vscode/package.nls.ja.json create mode 100644 vscode/package.nls.zh-cn.json diff --git a/.gitignore b/.gitignore index 6d4d9d06..f4eb0a2d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ vscode/blazor-wasm/ __pycache__/ *.pyc .pytest_cache/ + +## Translation handoff files — working files passed to and from a translator. +## The finished translations live in the resource files and are committed. +build/i18n/pending/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4a09950..da62ed58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,36 @@ By making a contribution to this project, I certify that: this project or the open source license(s) involved. ``` +## Writing a Message + +Verso's interface is translated, so a new string usually belongs in a resource file rather than +in the code that shows it. `build/i18n/README.md` covers where each one goes and how to add it. + +The question worth asking first is who reads it. A message is translated when the person reading +it can do something about it: a package that could not be downloaded, a parameter whose value does +not fit its type, a connection that has closed. Those go in a resource file with a note saying +where they appear. + +Four kinds stay in English wherever they appear, and each is marked with a comment in the code +saying so: + +- **Guards against programmer error.** `ArgumentNullException`, an internal + `InvalidOperationException`, a check that a method was called before the one that sets it up. + Nobody reading one can act on it except by changing code, and a stack trace that matches an + issue report is worth more than a translated one that does not. +- **Protocol shape.** The host answers the editor over a small JSON-RPC surface, and a request + missing a field it requires is a fault in the caller, not something a reader chose. Those read + the same in every language so a log from one machine matches a search from another. +- **Anything a script reads rather than a person.** The tags a run writes on its error stream, + and the status values in the document `--output json` produces. +- **Anything a model reads rather than a person.** The chat participant's prompt and the + descriptions of the tools it can call. + +Two shapes to avoid whatever the string says. Do not build a word from a stem and a letter +(`cell(s)`, `row(s)`, `entit{y|ies}`); write the two forms as separate entries and pick between +them with `Plural.Of`. Do not assemble a sentence from fragments; write it whole with numbered +placeholders, because another language will not put the pieces in that order. + ## Pull Requests - Target the `main` branch. diff --git a/build/i18n/README.md b/build/i18n/README.md index fc714b08..b7673f3f 100644 --- a/build/i18n/README.md +++ b/build/i18n/README.md @@ -2,13 +2,18 @@ Verso's interface is written in English and translated into German, Spanish, Japanese, and Simplified Chinese. The translations are committed files, so building Verso and checking -the translations need no API key and no network. Only regenerating them does. +the translations need no API key and no network. Strings live in two places. .NET code reads `.resx` files under `src//Resources`, one set per assembly, which the build turns into a satellite assembly per language. The editor extension reads `vscode/package.nls.json` for anything named in its manifest and `vscode/l10n/bundle.l10n.json` for the strings its own code shows. +One set per assembly is not a choice: a satellite assembly carries one assembly's resources, so a +kernel that ships as its own package needs its own. Each kernel therefore has a `Resources` folder +and a block in its `.csproj` that generates the accessor. `Plural` lives in `Verso.Abstractions`, +which every one of them already references. + ## Which file a string belongs in Two languages are in play, and a reader may set them differently. The editor writes its own @@ -23,14 +28,25 @@ JSON bundles; if it is drawn inside a notebook, it belongs in a `.resx`. Where t sends something a notebook will draw, it sends an identifier and the notebook chooses the words, which is what `DiffSources` does for the comparison baselines. -Two kinds of string stay in English wherever they appear, and both are marked with a comment +Four kinds of string stay in English wherever they appear, and each is marked with a comment in the code saying so: - **Anything read by a model rather than a person.** The chat participant's system prompt, the tool descriptions in the manifest, and everything the tools hand back. Translating them changes how well tools are chosen without changing anything a reader sees. +- **Anything read by a script rather than a person.** The `[stderr]` and `[error]` tags a run + writes, and the status values in the document `--output json` produces. A pipeline that reads + those would break the moment the machine running it was set to another language. - **Text that only a fault produces.** Log lines and guards against programmer error stay searchable, so a stack trace and an issue report still match. +- **The shape of the protocol.** The host answers the editor over a small JSON-RPC surface, and a + request missing a field it declares is a fault in the caller rather than something the reader + did. A log from one machine has to match a search made on another. + +The line between the last two and everything else is who can act on the message. A package that +would not download, a value that does not fit the type its parameter declares, a connection that +has since closed: the reader can do something about each of those, so each is translated. A cell +id that does not exist, or a request without the field it said it had, is a fault in code. ## Adding a string @@ -53,19 +69,65 @@ After editing extension code, re-export the bundle so the new strings reach a tr cd vscode && npx @vscode/l10n-dev export --outDir ./l10n ./src ``` -Then, from the repository root: +Then translate it, as below, and regenerate the pseudo-locale so the coverage sweep keeps +working. + +## Translating + +Two routes fill in a language, and they write the same files. Both ask only for the keys a +language does not already have, so adding a handful of English strings costs a handful of +translations rather than a retranslation of the interface. + +Read `glossary.md` before either. It is what keeps `kernel` from becoming three different +words in three files, and it lists what must not be translated at all. + +### Handing the strings to a translator + +`export.py` writes out what a language is missing, each string with its English and whatever +note the developer left beside it. Nothing about that file is particular to Verso, so it can +go to a person, to a translation service, or to an assistant in a session. ``` -python3 build/i18n/translate.py # fills in the four languages -python3 build/i18n/pseudo.py # regenerates the pseudo-locale -python3 build/i18n/check.py # confirms the four agree with the English +python3 build/i18n/export.py de --limit 100 +python3 build/i18n/export.py de --set Verso.Ado/Strings +``` + +The answer comes back through `merge.py`, in the shape its docstring gives: + ``` +python3 build/i18n/merge.py build/i18n/pending/de.answer.json +``` + +A translation that dropped a `{0}`, or came back empty, or answers a key English does not +have, is refused and named rather than written. Everything sound in the same run still lands, +so a rerun only has to cover what was named. + +Export, translate, merge, export again. The second export asks for what is still outstanding +and nothing else, which is what makes a language safe to do over several sittings without +anyone keeping track of where it got to. `pending/` is working files and is not committed. -`translate.py` only asks for keys a language does not already have, so this is cheap for a -handful of strings. It needs `pip install anthropic` and `ANTHROPIC_API_KEY`. +### Against the API + +`translate.py` does the whole of that in one command, which is the better route for somebody +outside the project who would rather spend an API key than an afternoon. + +``` +python3 build/i18n/translate.py --locale de +``` + +It needs `pip install anthropic` and `ANTHROPIC_API_KEY`. Nothing in the build or in +continuous integration runs it, so neither building Verso nor checking the translations +needs a key. + +### Either way, afterwards + +``` +python3 build/i18n/pseudo.py # regenerates the pseudo-locale +python3 build/i18n/check.py # confirms the languages agree with the English +``` -A machine translation is a draft. Have somebody who reads the language look over anything -user-facing before it ships. +A translation nobody has read is a draft, whichever route produced it. Have somebody who +reads the language look over anything user-facing before it ships. ## Counting things @@ -81,6 +143,68 @@ form and translate both entries the same way. A language with more forms than tw Russian or Polish, would need a real plural selector, and that is worth knowing before adding one. What must not happen is `cell(s)`, which no other language can copy. +In .NET the pair is chosen through `Plural.Of`. Where the count is dropped into a longer +sentence, write the count out on its own and pass the phrase in as an argument, so the +sentence needs one entry rather than a singular and a plural of the whole thing: + +```csharp +string.Format(Strings.Meta_Save_Done, CellCount.Describe(cells.Count), path) +``` + +## Words and styling + +The CLI writes coloured output through Spectre.Console, whose markup is written in square +brackets. None of it reaches a translator: `Messages` in `src/Verso.Cli/Utilities` fills a +translated sentence in and adds the styling around it, and everything substituted in is +escaped, because a file path can contain a bracket too. + +Where a sentence names something typed at a keyboard, that part is a placeholder rather than +part of the words, so it survives the sentence being rewritten: + +```csharp +Messages.Typed(Strings.Repl_UnsavedHint, ".save", ".load") +``` + +Colour goes on a whole line rather than on a word inside it. English puts the verb first, so +`Saved` could be picked out where it stood; a language that ends with its verb would leave +the colour on whatever happened to come first instead. + +## Sentences built from pieces + +A message that reports a count is the usual place a sentence gets assembled out of fragments, and +the usual place translation breaks. `"Installed " + list + " and " + n + " dependencies."` cannot +be translated at all: every join is a decision about word order that only English made. + +Write the whole sentence as one entry with numbered placeholders, and write any count out on its +own so it goes in as a single argument: + +```csharp +var dependencies = string.Format( + Plural.Of(rest.Count, Strings.Npm_DependencyCount_One, Strings.Npm_DependencyCount_Other), + rest.Count); + +return string.Format(Strings.Npm_InstalledAnd, Describe(named), dependencies); +``` + +Where a message continues on the same line or the next one, each part is still a whole entry +rather than a phrase glued on, and the entry carries its own leading space or line break. Where +something wraps an assembled sentence rather than following it, the wrapper takes the sentence as +its placeholder: `"{0} (execution failed)"` rather than `+ " (execution failed)"`. + +## Text a browser rewrites + +The table a SQL query comes back as repaints its own footer as the reader pages through it, so the +sentence goes into the page as a template with its placeholders intact and the script fills them +in. Assembling it there out of words and numbers would put it beyond a translator's reach, and the +static footer and the moving one would drift apart. + +## Things that line up + +A column heading, a padded label, and a rule drawn to a fixed width are all measured from the +string itself, never from a count written into the code. A translated heading is not the +length the English one was, and a table whose columns no longer line up reads as a fault +rather than as a translation. + ## Adding a language 1. Add the tag to `VersoCultures.Supported` in `src/Verso/Localization/VersoCultures.cs`. @@ -88,7 +212,7 @@ adding one. What must not happen is `cell(s)`, which no other language can copy. editor spells it differently, as it does for Chinese. 3. Add it to the `verso.language` setting's `enum` and `enumItemLabels` in `vscode/package.json`, and to `SHIPPED` in `vscode/src/localization.ts`. -4. Run `translate.py`, then `check.py`. +4. Translate it by either route above, then run `pseudo.py` and `check.py`. ## Checking the work diff --git a/build/i18n/export.py b/build/i18n/export.py new file mode 100644 index 00000000..c1599fcd --- /dev/null +++ b/build/i18n/export.py @@ -0,0 +1,121 @@ +"""Writes out the strings a language is still missing, for somebody to translate. + +`translate.py` does this and the translating in one go, against the API. This does the same +work up to the point where the words are actually chosen, and hands that part to whoever is +reading: an assistant in a session, a translator with a text editor, a translation service. +What comes back goes in through `merge.py`. + + python3 build/i18n/export.py de + python3 build/i18n/export.py de --set Verso.Ado/Strings # one resource set + python3 build/i18n/export.py de --limit 100 # a hundred at a time + python3 build/i18n/export.py de --all # including what is done + +Only the missing strings are asked for, so exporting again after a partial merge asks for +what is still outstanding and nothing else. That is what makes a language safe to do in +passes: run this, translate, merge, run this again. + +The file carries the English and whatever note the developer left, because that note is the +only context a translator gets. It does not carry the glossary, which is a document to read +once rather than a preamble to repeat. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from resources import LANGUAGE_NAMES, LOCALES, REPO_ROOT, discover, display + +# Working files, not artifacts. Ignored by git, because a half-finished German batch is +# nobody else's business and the finished translations live in the resource files. +PENDING = REPO_ROOT / "build" / "i18n" / "pending" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("locale", choices=LOCALES, help="The language to ask for.") + parser.add_argument( + "--set", + dest="sets", + action="append", + help="Limit to resource sets whose name contains this. Repeatable.", + ) + parser.add_argument( + "--limit", + type=int, + help="Ask for at most this many strings, so a large language can be done in passes.", + ) + parser.add_argument( + "--all", + action="store_true", + help="Include strings that already have a translation.", + ) + parser.add_argument("--out", type=Path, help="Where to write. Defaults to pending/.json") + args = parser.parse_args() + + sets = discover() + if not sets: + print("No neutral resource files found.", file=sys.stderr) + return 1 + + if args.sets: + wanted = [s.lower() for s in args.sets] + sets = [s for s in sets if any(w in s.name.lower() for w in wanted)] + if not sets: + print(f"No resource set matches {', '.join(args.sets)}.", file=sys.stderr) + return 1 + + body: dict[str, dict[str, dict[str, str]]] = {} + total = 0 + + for resource_set in sets: + if args.limit is not None and total >= args.limit: + break + + source = resource_set.source() + existing = resource_set.translation(args.locale) + + entries: dict[str, dict[str, str]] = {} + for key in sorted(source): + if not args.all and key in existing: + continue + if args.limit is not None and total + len(entries) >= args.limit: + break + + entry = {"en": source[key].value} + if source[key].comment: + entry["note"] = source[key].comment + entries[key] = entry + + if entries: + body[resource_set.name] = entries + total += len(entries) + + if not total: + print(f"{LANGUAGE_NAMES[args.locale]} is up to date. Nothing to export.") + return 0 + + out = args.out or (PENDING / f"{args.locale}.json") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + json.dumps( + {"locale": args.locale, "language": LANGUAGE_NAMES[args.locale], "sets": body}, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + for name, entries in body.items(): + print(f" {name}: {len(entries)}") + + print(f"\n{total} strings for {LANGUAGE_NAMES[args.locale]} in {display(out)}") + print("Read build/i18n/glossary.md, then answer with merge.py's shape.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/i18n/glossary.md b/build/i18n/glossary.md index 43e872fb..211643f6 100644 --- a/build/i18n/glossary.md +++ b/build/i18n/glossary.md @@ -18,7 +18,19 @@ These are names, not words. They appear as written in every language. environment variables such as `VERSO_LANGUAGE`, command-line options such as `--language`, MIME types, HTTP method names - Anything typed to make something happen: `@verso` addresses the chat assistant, `/props` - is a chat command, `HEAD` and `main` are version control names + is a chat command, `HEAD` and `main` are version control names, `.save` and `.exit` are + REPL commands, `all` and `none` are values a command accepts, `true/false`, `yes/no` and + `1/0` are the words a parameter of that type takes +- Keywords, methods, and header names that a specification defines: `SELECT`, `GROUP BY`, + `WHERE`, `LIMIT`, `GET`, `POST`, `Content-Type`, `Accept-Language`. Several entries open + with one and then explain it; explain it in the target language, and leave the word itself +- Command-line switches exactly as spelled: `--name`, `--connection-string`, `--show-output`, + `--list`. The words after them are prose and are translated +- Environment variable names: `VERSO_PYTHON`, `VERSO_LANGUAGE`, `PATH`, `NODE_PATH` +- Package, module, and library names: `typescript`, `pandas`, `numpy`, `npm`, `pip`, `uv`, + `FSharp.Compiler.Service`, `System.Management.Automation`, `Microsoft.Data.SqlClient` +- Argument names as the help text spells them: ``, ``, `..`, + `name=value`. They describe what to type, so they are read alongside what was typed - The names languages call themselves. A language picker lists **English**, **Deutsch**, **Español**, **日本語**, **简体中文**, and those read the same whichever language the picker is in. Only the entry meaning "take it from the editor" is a word to translate. @@ -52,6 +64,194 @@ language, keep the English word. | parameter | A named value a notebook declares and a caller supplies | | variable | A value produced by running a cell | | trust | The user's decision to let something run | +| meta-command | A dot-prefixed command the REPL answers itself, such as `.save` | +| magic command | A `#!`-prefixed directive a cell can carry, such as `#!pip` | +| session | One run of the REPL, and the notebook it is building | +| package | Something installed from an index: a NuGet package, a Python distribution, an npm module | +| dependency | A package that came along with one that was asked for | +| interpreter | The Python installation a notebook's cells run in | +| connection | A named, open link to a database that SQL cells run against | +| result set | The rows one query came back with | +| schema | What a database holds: its tables, views, and columns. Also the grouping a table belongs to, which is the column heading `Schema` | + +## Terms already chosen + +Where a house term above has been settled in a language, it is recorded here so the next batch +matches the last one rather than deciding again. A reviewer who disagrees should change the term +everywhere and update this table, not just the entry in front of them. + +### German + +| English | German | Why | +|---|---|---| +| notebook | Notebook | What German practitioners say. Not Notizbuch, which is the paper kind | +| cell | Zelle | | +| output | Ausgabe | | +| kernel | Kernel | Plural is also Kernel | +| extension | Erweiterung | Matches the editor's own German | +| panel | Panel | Matches the editor's own German | +| theme | Design | The editor calls it Design, so a reader meets the same word twice | +| layout | Layout | | +| baseline | Basis | Also Basisversion where a version rather than a file is meant | +| package | Paket | | +| dependency | Abhängigkeit | | +| variable | Variable | | +| parameter | Parameter | | +| magic command | Magic Command | Untranslated, as Jupyter users say it | +| meta-command | Meta-Befehl | | +| result set | Ergebnismenge | | +| schema | Schema | | +| member (of a type) | Member | Singular and plural alike, as Microsoft's German docs have it | +| renderer, handler | Renderer, Handler | Kept, as German developers say them | +| serializer | Serialisierer | | +| formatter | Formatierer | | + +One departure from a note: `Serve_PressCtrlC` says `Ctrl+C` is the same in every language, but a +German keyboard prints **Strg**, so the German reads `Strg+C`. Key names follow the keyboard the +reader has, which is the rule the `Key_*` entries already state. + +### Spanish + +| English | Spanish | Why | +|---|---|---| +| notebook | cuaderno | What the editor itself says in Spanish, so a reader meets the same word twice | +| cell | celda | | +| output | salida | | +| kernel | kernel | Plural is kernels | +| extension | extensión | | +| panel | panel | | +| theme | tema | | +| layout | diseño | The editor's own word. It does not collide with **tema**, so both stay plain | +| dashboard | Dashboard | Kept. The obvious translation is *panel*, which already means the docked region | +| baseline | línea base | | +| package | paquete | | +| dependency | dependencia | | +| variable | variable | | +| parameter | parámetro | | +| magic command | comando mágico | | +| meta-command | metacomando | | +| result set | conjunto de resultados | | +| schema | esquema | | +| member (of a type) | miembro | | +| handler | controlador | Microsoft's Spanish term, which is what the editor uses | +| renderer, render | renderizador, renderizar | What Spanish developers say, over Microsoft's *representador* | +| serializer | serializador | | +| formatter | formateador | | +| commit (version control) | confirmación | | +| required | obligatorio | | +| default | predeterminado | | + +Two collisions worth knowing about, because the English words are distinct and the obvious +Spanish is not. `Table_Kind` (light or dark) is **Clase** so that `Table_Type` can stay **Tipo**, +and `Table_DisplayName` is **Nombre visible** so that `Table_Name` can stay **Nombre**. + +Where a count and an adjective have to agree, the count is moved rather than guessed at. The npm +audit severities read `altas: {0}` rather than `{0} altas`, because `{0}` is 1 as often as not and +Spanish would need `alta` there. + +### Japanese + +| English | Japanese | Why | +|---|---|---| +| notebook | ノートブック | What the editor itself says in Japanese | +| cell | セル | | +| output | 出力 | | +| kernel | カーネル | | +| extension | 拡張機能 | Matches the editor's own Japanese | +| panel | パネル | | +| theme | テーマ | | +| layout | レイアウト | | +| dashboard | ダッシュボード | No collision with **パネル**, so both stay plain | +| baseline | ベースライン | | +| package | パッケージ | | +| dependency | 依存関係 | | +| variable | 変数 | | +| parameter | パラメーター | With the long vowel mark, as Microsoft's Japanese has it | +| magic command | マジックコマンド | | +| meta-command | メタコマンド | | +| result set | 結果セット | | +| schema | スキーマ | | +| member (of a type) | メンバー | | +| renderer, handler, formatter, serializer | レンダラー、ハンドラー、フォーマッター、シリアライザー | Transliterated, as Japanese developers say them | +| commit (version control) | コミット | | +| required | 必須 | | +| default | 既定 | Microsoft's Japanese, over デフォルト | +| run / execute | 実行 | One word for both, as the source asks | + +Japanese has one form where English has two, so every `_One` and `_Other` pair is translated +identically. That is expected here and not a copy-and-paste slip. + +Counts read as **{0} 件** or **{0} 個** rather than being placed like an English adjective. The +npm audit severities are **高 {0} 件**, not **{0} 高**, because Japanese puts the counter after the +number and the classifier before it. + +Two headings need distinct words the obvious translation would collapse. `Table_Kind` (light or +dark) is **種類** so that `Table_Type` can stay **型**, and `Magic_Schema_ColumnNullable` is +**NULL 可** rather than a phrase, because the column is narrow and NULL is written the same in +every language. + +One string is worth a reviewer's eye: `configuration.showOpenInVersoMenu.description` quotes the +editor's own **Reopen Editor With...** command, rendered here as 「エディターを再度開く...」. If +VS Code's Japanese words that command differently, match the editor rather than this file. + +### Simplified Chinese + +| English | Simplified Chinese | Why | +|---|---|---| +| notebook | 笔记本 | What the editor itself says in Chinese | +| cell | 单元格 | Matches the editor's own Chinese | +| output | 输出 | | +| kernel | 内核 | Matches the editor's own Chinese | +| extension | 扩展 | Matches the editor's own Chinese | +| panel | 面板 | | +| theme | 主题 | | +| layout | 布局 | | +| dashboard | 仪表板 | No collision with **面板**, so both stay plain | +| baseline | 基线 | | +| package | 包 | | +| dependency | 依赖项 | | +| variable | 变量 | | +| parameter | 参数 | | +| magic command | 魔法命令 | | +| meta-command | 元命令 | | +| result set | 结果集 | | +| schema (of a database) | 架构 | Microsoft's Chinese for the database sense, not 模式 | +| repository (version control) | 存储库 | Microsoft's Chinese, which the editor also uses | +| tag (version control) | 标记 | | +| tag (on a cell) | 标签 | Deliberately not 标记, so a cell tag never reads as a git tag | +| member (of a type) | 成员 | | +| renderer, render | 渲染器、渲染 | What Chinese developers say, over Microsoft's 呈现 | +| handler | 处理程序 | | +| provider | 提供程序 | | +| formatter | 格式化程序 | | +| serializer | 序列化程序 | | +| commit (version control) | 提交 | | +| required | 必需 | | +| default | 默认 | | +| run / execute | 运行 | One word for both, as the source asks | +| widget | 小组件 | | + +Chinese has one form where English has two, so every `_One` and `_Other` pair is translated +identically. That is expected here and not a copy-and-paste slip. + +Counts read as **{0} 个** or **{0} 项** rather than being placed like an English adjective, and a +count that heads a clause moves behind its verb: `Compare_SummaryAdded` is **已添加 {0} 个**, not +**{0} 已添加**. The npm audit severities are **高 {0} 个**, because Chinese puts the measure word +after the number and the severity before it. + +Three headings need distinct words the obvious translation would collapse. `Table_Kind` (light or +dark) is **种类** so that `Table_Type` can stay **类型**; `Table_Extensions` (the file extensions a +format is read from) is **扩展名** so that the add-ons can stay **扩展**; and `Common_Dismiss` is +**忽略** so that `Common_Close` can stay **关闭**, since both would otherwise be 关闭. + +Punctuation follows Chinese convention: full-width **,。:()** with no space before them, and +**、** between items in a list. The three-dot ellipsis of the source is kept as typed, because +that is what the editor's own Chinese does. + +One string is worth a reviewer's eye, the same one Japanese flagged: +`configuration.showOpenInVersoMenu.description` quotes the editor's own **Reopen Editor With...** +command, rendered here as “重新打开文件方式...”. If VS Code's Chinese words that command +differently, match the editor rather than this file. ## Tone and shape diff --git a/build/i18n/merge.py b/build/i18n/merge.py new file mode 100644 index 00000000..121aea58 --- /dev/null +++ b/build/i18n/merge.py @@ -0,0 +1,115 @@ +"""Puts translated strings back into the files the build reads. + +Takes what `export.py` asked for and somebody answered, and writes each string into the +resource file for its language. Translations already there are kept, so a language can be +done in passes, and a key English no longer has is dropped rather than carried in four +languages under a name nothing looks up. + + python3 build/i18n/merge.py build/i18n/pending/de.answer.json + +The answer names its own language, so there is no argument to get wrong. Its shape is the +export's, with each string in place of the object describing it: + + { + "locale": "de", + "sets": { + "Verso.Blazor.Shared/UI": { "Toolbar_Run": "Ausführen" } + } + } + +A translation that dropped a placeholder, or came back empty, or answers a key English does +not have, is refused and named. Writing it would leave a message with a hole in it that +nothing notices until it is finally shown. Everything sound in the same run is still +written, so a rerun only has to cover what was named. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from resources import LANGUAGE_NAMES, LOCALES, Entry, discover, display, placeholders + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("answer", type=Path, help="The filled-in file to read.") + args = parser.parse_args() + + if not args.answer.exists(): + print(f"{args.answer} does not exist.", file=sys.stderr) + return 1 + + payload = json.loads(args.answer.read_text(encoding="utf-8")) + locale = payload.get("locale") + if locale not in LOCALES: + print(f"The file names its language as {locale!r}, which Verso does not ship.", file=sys.stderr) + return 1 + + by_name = {resource_set.name: resource_set for resource_set in discover()} + refused: list[str] = [] + written = 0 + + for name, answers in payload.get("sets", {}).items(): + resource_set = by_name.get(name) + if resource_set is None: + refused.append(f"{name}: no such resource set") + continue + + source = resource_set.source() + existing = resource_set.translation(locale) + accepted: dict[str, str] = {} + + for key, value in answers.items(): + if not isinstance(value, str): + refused.append(f"{name}/{key}: expected a translation, found {type(value).__name__}") + elif key not in source: + refused.append(f"{name}/{key}: not a key English has") + elif not value.strip(): + refused.append(f"{name}/{key}: empty") + elif sorted(placeholders(source[key].value)) != sorted(placeholders(value)): + refused.append( + f"{name}/{key}: placeholders differ, English has " + f"{sorted(placeholders(source[key].value)) or 'none'} and this has " + f"{sorted(placeholders(value)) or 'none'}" + ) + else: + accepted[key] = value + + if not accepted: + continue + + # Rebuilt from the English keys rather than updated in place, so a key that was + # renamed or dropped since the last pass leaves with it. Notes stay in the neutral + # file: they are written for a translator, and nothing reads them back out of here. + merged = { + key: Entry(accepted.get(key) or existing[key].value) + for key in source + if key in accepted or key in existing + } + + path = resource_set.path_for(locale) + path.parent.mkdir(parents=True, exist_ok=True) + resource_set.save(path, merged) + + outstanding = len(source) - len(merged) + note = f", {outstanding} still missing" if outstanding else "" + print(f" {display(path)}: {len(accepted)} written, {len(merged)} of {len(source)}{note}") + written += len(accepted) + + plural = "string" if written == 1 else "strings" + print(f"\n{written} {plural} merged into {LANGUAGE_NAMES[locale]}.", flush=True) + + if refused: + print(f"\n{len(refused)} refused:", file=sys.stderr) + for problem in refused: + print(f" {problem}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/i18n/resources.py b/build/i18n/resources.py index 9790bb5a..1ca13030 100644 --- a/build/i18n/resources.py +++ b/build/i18n/resources.py @@ -5,8 +5,8 @@ around them would each grow two code paths, so both are wrapped here and everything else in this directory works in terms of keys, values, and translator notes. -Run nothing here directly. `pseudo.py`, `translate.py`, and `check.py` are the entry -points. +Run nothing here directly. `export.py`, `merge.py`, `translate.py`, `pseudo.py`, and +`check.py` are the entry points. """ from __future__ import annotations @@ -146,6 +146,16 @@ class ResourceSet: def __init__(self, neutral: Path): self.neutral = neutral + @property + def name(self) -> str: + """A short, stable way to name this set in a report or a handoff file. + + The full path is unwieldy to type and to read, and the file name alone is + ambiguous: ten assemblies each have a `Strings.resx`. So a set is named by what + distinguishes it, which is the assembly it belongs to. + """ + raise NotImplementedError + def path_for(self, locale: str) -> Path: raise NotImplementedError @@ -167,6 +177,12 @@ def translation(self, locale: str) -> dict[str, Entry]: class ResxSet(ResourceSet): """A .NET resource file, which compiles into one satellite assembly per language.""" + @property + def name(self) -> str: + # `src/Verso.Ado/Resources/Strings.resx` is named `Verso.Ado/Strings`, which is the + # assembly whose satellite it becomes and the file within it. + return f"{self.neutral.parents[1].name}/{self.neutral.stem}" + def path_for(self, locale: str) -> Path: return self.neutral.with_name(f"{self.neutral.stem}.{locale}.resx") @@ -211,6 +227,11 @@ class JsonSet(ResourceSet): object carrying the string plus notes for whoever translates it. """ + @property + def name(self) -> str: + # Already unique and already short, so the path is the name, less the extension. + return display(self.neutral)[: -len(".json")] + def path_for(self, locale: str) -> Path: stem = self.neutral.name[: -len(".json")] return self.neutral.with_name(f"{stem}.{VSCODE_IDS.get(locale, locale)}.json") diff --git a/build/i18n/translate.py b/build/i18n/translate.py index aedc66ce..854f65e0 100644 --- a/build/i18n/translate.py +++ b/build/i18n/translate.py @@ -1,10 +1,14 @@ -"""Fills in the translations that are missing from the shipped languages. +"""Fills in the translations that are missing from the shipped languages, against the API. Reads every neutral resource file, works out which keys a language has not been given yet, and asks Claude for those and only those. Existing translations are left alone, so adding a handful of English strings costs a handful of translations rather than a retranslation of the interface. +This is one of two routes, and the one that costs an API key. `export.py` and `merge.py` +are the other: they do the same reading and the same writing, and hand the part in between +to whoever is translating. See `README.md`. + pip install anthropic export ANTHROPIC_API_KEY=... python3 build/i18n/translate.py # everything missing, all languages diff --git a/src/Verso.Abstractions/Localization/Plural.cs b/src/Verso.Abstractions/Localization/Plural.cs new file mode 100644 index 00000000..f1d83ab7 --- /dev/null +++ b/src/Verso.Abstractions/Localization/Plural.cs @@ -0,0 +1,29 @@ +namespace Verso.Abstractions; + +/// +/// Chooses between the two forms of a message that counts something. +/// +/// +/// Two forms cover every language Verso ships in: German and Spanish need a singular and a +/// plural, and Japanese and Chinese need neither. A language with more forms, such as Russian +/// or Polish, does not fit here and would need a real plural selector keyed on the count and +/// the language together. Adding one of those means replacing this, not adding a third key. +/// +/// The alternative, building a word out of a stem and an s, does not survive translation +/// at all: the plural of a German noun is not its singular with a letter on the end. Neither +/// does cell(s), which no other language can copy. +/// +/// +/// It lives here, in the assembly every other one already references, because the notebook +/// interface, the engine, the command line, and each kernel all count something. An extension +/// writing its own messages can use it for the same reason. +/// +/// +public static class Plural +{ + /// Picks the singular for exactly one, and the plural for anything else. + /// How many things the message is about. + /// The message written for a single thing. + /// The message written for any other number of them. + public static string Of(int count, string one, string other) => count == 1 ? one : other; +} diff --git a/src/Verso.Abstractions/Theming/ThemeCss.cs b/src/Verso.Abstractions/Theming/ThemeCss.cs new file mode 100644 index 00000000..80871849 --- /dev/null +++ b/src/Verso.Abstractions/Theming/ThemeCss.cs @@ -0,0 +1,143 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Text; + +namespace Verso.Abstractions; + +/// +/// Builds the :root { --verso-*: ...; } custom property block that Verso surfaces +/// style themselves from. +/// +/// +/// +/// There is one emitter rather than one per surface. The notebook interface and the +/// self-contained HTML export need the same block, and while each kept its own copy the two +/// drifted: the export never emitted the typography tokens that are not fonts, so +/// --verso-font-family-mono, --verso-font-family-sans and +/// --verso-font-size-base were missing from every exported document. +/// +/// +/// Every number is written with the invariant culture. A stylesheet is not read in anyone's +/// language: a length is 1.4 whoever is looking at it, never 1,4, and a browser +/// discards a declaration it cannot parse. Callers cannot get this wrong by accident because +/// they never format a number themselves. +/// +/// +public static class ThemeCss +{ + /// + /// Builds the custom property block for a theme, falling back to the default tokens for + /// anything the theme does not supply. + /// + public static string BuildRootBlock(ITheme? theme) => + BuildRootBlock(theme?.Colors, theme?.Typography, theme?.Spacing, theme?.Elevation); + + /// + /// Builds the custom property block from individual token groups, falling back to the + /// defaults for any group that is null. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ThemeColorTokens))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ThemeTypography))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ThemeSpacing))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ThemeElevation))] + public static string BuildRootBlock( + ThemeColorTokens? colors, + ThemeTypography? typography, + ThemeSpacing? spacing, + ThemeElevation? elevation) + { + var sb = new StringBuilder(); + sb.AppendLine(":root {"); + AppendColors(sb, colors ?? new ThemeColorTokens()); + AppendTypography(sb, typography ?? new ThemeTypography()); + AppendSpacing(sb, spacing ?? new ThemeSpacing()); + AppendElevation(sb, elevation ?? new ThemeElevation()); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void AppendColors(StringBuilder sb, ThemeColorTokens colors) + { + foreach (var prop in typeof(ThemeColorTokens).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.PropertyType != typeof(string)) continue; + var value = (string?)prop.GetValue(colors) ?? ""; + sb.AppendLine($" --verso-{ToKebabCase(prop.Name)}: {value};"); + } + } + + private static void AppendTypography(StringBuilder sb, ThemeTypography typography) + { + foreach (var prop in typeof(ThemeTypography).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var name = ToKebabCase(prop.Name); + if (prop.PropertyType == typeof(FontDescriptor)) + { + if (prop.GetValue(typography) is not FontDescriptor font) continue; + sb.AppendLine($" --verso-{name}-family: {font.Family};"); + sb.AppendLine(CultureInfo.InvariantCulture, $" --verso-{name}-size: {font.SizePx}px;"); + sb.AppendLine(CultureInfo.InvariantCulture, $" --verso-{name}-weight: {font.Weight};"); + sb.AppendLine(CultureInfo.InvariantCulture, $" --verso-{name}-line-height: {font.LineHeight};"); + } + else if (prop.PropertyType == typeof(string)) + { + if (prop.GetValue(typography) is not string value) continue; + sb.AppendLine($" --verso-{name}: {value};"); + } + else if (prop.PropertyType == typeof(double)) + { + var value = (double)prop.GetValue(typography)!; + sb.AppendLine(CultureInfo.InvariantCulture, $" --verso-{name}: {value}px;"); + } + } + } + + private static void AppendSpacing(StringBuilder sb, ThemeSpacing spacing) + { + foreach (var prop in typeof(ThemeSpacing).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.PropertyType != typeof(double)) continue; + var value = (double)prop.GetValue(spacing)!; + sb.AppendLine(CultureInfo.InvariantCulture, $" --verso-{ToKebabCase(prop.Name)}: {value}px;"); + } + } + + private static void AppendElevation(StringBuilder sb, ThemeElevation elevation) + { + // Elevation properties are named Level0..Level3. The prefix is dropped so a stylesheet + // reads var(--verso-elevation-1) rather than var(--verso-elevation-level1). + foreach (var prop in typeof(ThemeElevation).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.PropertyType != typeof(string)) continue; + if (prop.GetValue(elevation) is not string value) continue; + var suffix = ToKebabCase(prop.Name.StartsWith("Level", StringComparison.Ordinal) + ? prop.Name["Level".Length..] + : prop.Name); + sb.AppendLine($" --verso-elevation-{suffix}: {value};"); + } + } + + /// + /// Converts a PascalCase property name to the spelling used in a custom property name, + /// so BgDefault becomes bg-default. + /// + private static string ToKebabCase(string name) + { + var sb = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + if (i > 0) sb.Append('-'); + sb.Append(char.ToLowerInvariant(c)); + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } +} diff --git a/src/Verso.Ado/CellType/SqlCellRenderer.cs b/src/Verso.Ado/CellType/SqlCellRenderer.cs index 99534915..c6e4dd24 100644 --- a/src/Verso.Ado/CellType/SqlCellRenderer.cs +++ b/src/Verso.Ado/CellType/SqlCellRenderer.cs @@ -2,6 +2,7 @@ using System.Text; using Verso.Abstractions; using Verso.Ado.MagicCommands; +using Verso.Ado.Resources; namespace Verso.Ado.CellType; @@ -17,7 +18,7 @@ public sealed class SqlCellRenderer : ICellRenderer public string Name => "SQL Renderer"; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Renders SQL cells with connection indicator badges."; + public string? Description => Strings.Renderer_Description; // --- ICellRenderer --- diff --git a/src/Verso.Ado/CellType/SqlCellType.cs b/src/Verso.Ado/CellType/SqlCellType.cs index 6de04a17..6b10ae12 100644 --- a/src/Verso.Ado/CellType/SqlCellType.cs +++ b/src/Verso.Ado/CellType/SqlCellType.cs @@ -1,5 +1,6 @@ using Verso.Abstractions; using Verso.Ado.Kernel; +using Verso.Ado.Resources; namespace Verso.Ado.CellType; @@ -16,7 +17,7 @@ public sealed class SqlCellType : ICellType public string Name => "SQL Cell Type"; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "SQL cell type for querying databases via ADO.NET."; + public string? Description => Strings.CellType_Description; public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask; public Task OnUnloadedAsync() => Task.CompletedTask; diff --git a/src/Verso.Ado/Formatters/ResultSetFormatter.cs b/src/Verso.Ado/Formatters/ResultSetFormatter.cs index ca375d7e..77236ae6 100644 --- a/src/Verso.Ado/Formatters/ResultSetFormatter.cs +++ b/src/Verso.Ado/Formatters/ResultSetFormatter.cs @@ -4,6 +4,9 @@ using System.Text.Json; using Verso.Abstractions; using Verso.Ado.Models; +using Verso.Ado.Resources; +using System.Text.Json; +using Verso.Abstractions; namespace Verso.Ado.Formatters; @@ -21,7 +24,7 @@ public sealed class ResultSetFormatter : IDataFormatter public string Name => "Result Set Formatter"; public string Version => "1.0.0"; public string? Author => "Verso Contributors"; - public string? Description => "Formats SQL result sets and DataTables as paginated HTML tables."; + public string? Description => Strings.Formatter_Description; // --- IDataFormatter --- @@ -105,21 +108,19 @@ internal static string FormatResultSetHtml(SqlResultSet resultSet, IThemeContext } else { - sb.Append("
Showing 1-") - .Append(totalRows.ToString("N0")) - .Append(" of ") - .Append(totalRows.ToString("N0")) - .Append(" rows
"); + sb.Append("
") + .Append(WebUtility.HtmlEncode(string.Format(Strings.Table_ShowingRows, + 1, totalRows.ToString("N0"), totalRows.ToString("N0")))) + .Append("
"); } // Truncation warning if (resultSet.WasTruncated) { - sb.Append("
Results truncated at ") - .Append(resultSet.Rows.Count.ToString("N0")) - .Append(" of ") - .Append(resultSet.TotalRowCount.ToString("N0")) - .Append(" total rows. Use WHERE or LIMIT to narrow your query.
"); + sb.Append("
") + .Append(WebUtility.HtmlEncode(string.Format(Strings.Table_Truncated, + resultSet.Rows.Count.ToString("N0"), resultSet.TotalRowCount.ToString("N0")))) + .Append("
"); } sb.Append("
"); @@ -170,10 +171,15 @@ internal static string FormatNonQueryHtml(int rowsAffected, int statementCount, AppendStyles(sb, theme); sb.Append("
"); sb.Append("
"); - sb.Append(rowsAffected.ToString("N0")).Append(" row(s) affected"); + var affected = string.Format( + Plural.Of(rowsAffected, Strings.Table_RowsAffected_One, Strings.Table_RowsAffected_Other), + rowsAffected.ToString("N0")); if (statementCount > 1) - sb.Append(" (").Append(statementCount).Append(" statements)"); - sb.Append(" (").Append(elapsedMs).Append(" ms)"); + affected = string.Format(Strings.Table_Statements, affected, statementCount); + sb.Append(WebUtility.HtmlEncode(affected)); + sb.Append(" (") + .Append(WebUtility.HtmlEncode(string.Format(Strings.Table_Elapsed, elapsedMs))) + .Append(")"); sb.Append("
"); return sb.ToString(); } @@ -186,7 +192,7 @@ internal static string FormatNonQueryHtml(int rowsAffected, int statementCount, /// fallback chain so the same HTML adapts to any host environment: /// /// --vscode-* — VS Code notebook output webview - /// --verso-* — Blazor shell / HTML export (set by ThemeProvider / ThemeCssGenerator) + /// --verso-* — Blazor shell / HTML export (set by ThemeCss.BuildRootBlock) /// literal value — safety net for isolated HTML /// /// The parameter is retained for API compatibility but @@ -241,12 +247,15 @@ internal static void AppendStyles(StringBuilder sb, IThemeContext? theme) private static void AppendPagingScript(StringBuilder sb, int totalRows, int pageSize) { sb.Append("
"); - sb.Append(""); - sb.Append(""); + sb.Append(""); + sb.Append(""); sb.Append(""); sb.Append("
"); sb.Append("