From 0cef414e9af5b4545bff2b6052fe854f757da185 Mon Sep 17 00:00:00 2001
From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
Date: Wed, 10 Jun 2026 22:19:24 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20Summarum=20=E2=80=94=20natural=20langua?=
=?UTF-8?q?ge=20calculator=20notebook=20for=20Windows?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Write plain text, get live results per line. English + Russian, units,
live currency rates with crypto, percents, dates and timezones, bitwise
ops, variables and sheet totals. Tauri 2 + TypeScript engine +
CodeMirror 6; daily backups and a deleted-sheets bin.
Inspired by Numi for macOS (numi.app); built from scratch for Windows.
---
.gitignore | 5 +
ARCHITECTURE.md | 119 +
LICENSE | 6 +
README.md | 136 +
index.html | 98 +
package-lock.json | 2064 ++++++++++
package.json | 37 +
scripts/gen-icon.ts | 156 +
src-tauri/Cargo.lock | 5745 +++++++++++++++++++++++++++
src-tauri/Cargo.toml | 26 +
src-tauri/build.rs | 3 +
src-tauri/capabilities/default.json | 25 +
src-tauri/extension-sample.js | 21 +
src-tauri/icons/128x128.png | Bin 0 -> 971 bytes
src-tauri/icons/128x128@2x.png | Bin 0 -> 2015 bytes
src-tauri/icons/32x32.png | Bin 0 -> 249 bytes
src-tauri/icons/icon.ico | Bin 0 -> 4273 bytes
src-tauri/icons/icon.png | Bin 0 -> 4597 bytes
src-tauri/src/main.rs | 475 +++
src-tauri/tauri.conf.json | 54 +
src/engine/datetime.ts | 109 +
src/engine/edge.test.ts | 144 +
src/engine/engine.test.ts | 241 ++
src/engine/evaluator.ts | 466 +++
src/engine/extraunits.ts | 98 +
src/engine/formatter.ts | 116 +
src/engine/index.ts | 217 +
src/engine/lexer.ts | 79 +
src/engine/parser.ts | 389 ++
src/engine/rates-snapshot.json | 33 +
src/engine/registry.ts | 394 ++
src/engine/tokenizer.ts | 128 +
src/engine/types.ts | 90 +
src/engine/unitdata.ts | 135 +
src/engine/vocab-data.ts | 1015 +++++
src/engine/vocab.ts | 72 +
src/extensions.ts | 29 +
src/i18n.ts | 101 +
src/main.ts | 576 +++
src/storage.ts | 241 ++
src/ui/app.css | 399 ++
src/ui/editor.ts | 279 ++
tsconfig.json | 20 +
vite.config.ts | 13 +
44 files changed, 14354 insertions(+)
create mode 100644 .gitignore
create mode 100644 ARCHITECTURE.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 scripts/gen-icon.ts
create mode 100644 src-tauri/Cargo.lock
create mode 100644 src-tauri/Cargo.toml
create mode 100644 src-tauri/build.rs
create mode 100644 src-tauri/capabilities/default.json
create mode 100644 src-tauri/extension-sample.js
create mode 100644 src-tauri/icons/128x128.png
create mode 100644 src-tauri/icons/128x128@2x.png
create mode 100644 src-tauri/icons/32x32.png
create mode 100644 src-tauri/icons/icon.ico
create mode 100644 src-tauri/icons/icon.png
create mode 100644 src-tauri/src/main.rs
create mode 100644 src-tauri/tauri.conf.json
create mode 100644 src/engine/datetime.ts
create mode 100644 src/engine/edge.test.ts
create mode 100644 src/engine/engine.test.ts
create mode 100644 src/engine/evaluator.ts
create mode 100644 src/engine/extraunits.ts
create mode 100644 src/engine/formatter.ts
create mode 100644 src/engine/index.ts
create mode 100644 src/engine/lexer.ts
create mode 100644 src/engine/parser.ts
create mode 100644 src/engine/rates-snapshot.json
create mode 100644 src/engine/registry.ts
create mode 100644 src/engine/tokenizer.ts
create mode 100644 src/engine/types.ts
create mode 100644 src/engine/unitdata.ts
create mode 100644 src/engine/vocab-data.ts
create mode 100644 src/engine/vocab.ts
create mode 100644 src/extensions.ts
create mode 100644 src/i18n.ts
create mode 100644 src/main.ts
create mode 100644 src/storage.ts
create mode 100644 src/ui/app.css
create mode 100644 src/ui/editor.ts
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..29dd4b6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+src-tauri/target/
+src-tauri/gen/
+*.log
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..7142d22
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,119 @@
+# Architecture
+
+For people who want to read or change the code. The app is two parts:
+a **TypeScript calculation engine + UI** running in WebView2, and a thin
+**Rust shell** (Tauri 2) for everything OS-specific.
+
+```
+src/
+ engine/ the calculator itself (pure TS, no DOM)
+ lexer.ts line → raw lexemes (numbers, words, symbols)
+ vocab-data.ts en+ru word tables: unit names, currencies, operators
+ vocab.ts merged lookup over the tables
+ unitdata.ts conversion ratios (base units per dimension)
+ extraunits.ts speed/pressure/energy/power/frequency/fuel + crypto list
+ registry.ts phrase → token payload; built once at startup
+ tokenizer.ts lexemes + registry → semantic tokens
+ parser.ts tokens → AST (noise-tolerant Pratt parser)
+ evaluator.ts AST + document context → values with units
+ formatter.ts values → display strings
+ datetime.ts timezone map, calendar arithmetic
+ index.ts SumEngine facade + extension API + sheet totals
+ ui/
+ editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
+ app.css themes (light/dark via CSS variables)
+ main.ts app bootstrap: documents, settings, status bar, dnd
+ storage.ts Tauri commands / localStorage fallback
+ extensions.ts runs user .js files against the engine
+ i18n.ts interface strings (en/ru)
+src-tauri/
+ src/main.rs tray, hide-to-tray, storage commands, rates fetching
+ (open.er-api.com + CoinGecko) with cache, backups,
+ data-folder migration, file drops
+```
+
+## How a line becomes a result
+
+1. **Lexer** splits `"5 inches in cm"` into lexemes: `5`, `inches`, `in`, `cm`.
+ The same lexer segments vocabulary phrases, so matching is consistent.
+2. **Registry** holds every phrase the engine knows, keyed by first lexeme.
+ Longest match wins; case-sensitive entries (`m` vs `M`, `kB`) are tried
+ before case-insensitive ones, and an ambiguous lowercase form (`mm` vs `Mm`)
+ simply doesn't get a case-insensitive fallback.
+3. **Tokenizer** walks the lexemes, asks the registry, and emits typed tokens.
+ A couple of context hacks live here, e.g. `in` right after a number at the
+ end of a line means inches (`6 ft 3 in`).
+4. **Parser** is a Pratt parser that tolerates noise: unknown words are
+ dropped before parsing (that's why `spent 20 on pizza plus 5` works) —
+ except words right after a conversion operator, which may name a timezone.
+ Precedence, loosest to tightest: line-level conversions (`in cm` applies to
+ the merged line, so `2 hours 30 minutes in minutes` works) → bitwise →
+ additive → multiplicative (incl. `20% of x`) → unary → postfix
+ (units, `%`, `!`, `^`) → primary.
+5. **Evaluator** computes `{ value: Decimal, unit }` triples. Same-dimension
+ operands convert through base units (`toBase`/`fromBase`); temperatures
+ carry an offset, fuel economy units can be reciprocal (mpg ↔ L/100km).
+ The document context supplies variables and previous line results for
+ `sum`/`avg`/`prev`.
+6. **Formatter** renders the value with the unit's display format, precision
+ and grouping from settings, or as hex/binary/fraction/roman/etc.
+
+Numbers are `decimal.js` throughout — no binary float artifacts.
+
+## Adding things
+
+**A unit** — add a row to `UNIT_DATA` in `unitdata.ts` (id, dimension, ratio
+to the dimension base) and its names to `vocab-data.ts` under the matching
+category (`"myunit.variants": "name,names,имя"`, `"myunit.format": "sym"`).
+For a brand-new dimension, extend the `Dimension` union in `types.ts` and use
+`extraunits.ts` — phrases live inline there.
+
+**A currency** — fiat codes come from the `Currency` table in `vocab-data.ts`;
+crypto goes in `CRYPTO` in `extraunits.ts` (code, CoinGecko id, phrases,
+snapshot price) plus the mirror list in `src-tauri/src/main.rs`.
+
+**An operator/function** — register the phrase in `buildRegistry()`
+(`registry.ts`), add a token/AST case if it needs new syntax, implement in
+`evaluator.ts`. Most word-operators are data, not code.
+
+**A timezone city** — one line in the `Z` map in `datetime.ts`.
+
+## Engine gotchas
+
+- Phrase conflicts are real: single letters are case-sensitive by design
+ (`m` meter / `M` million, `k` thousand / `K` kelvin). Prefix generation is
+ filtered where it collides with words — e.g. seconds only get ms/µs/ns/ps
+ because an "attosecond" symbol would shadow the word `as`.
+- `and`/`or` are dual-purpose: `and` adds (`5 and 3` = 8), `or` is bitwise.
+ `of/off/on` only act on a percent left-hand side; otherwise they're prose.
+- Currency rates are "units per USD"; a currency Unit's ratio is computed at
+ tokenization time from the current rate map, so re-setting rates re-prices
+ everything on the next evaluation.
+- The registry is rebuilt never; extensions mutate it (add phrases/functions)
+ at startup.
+
+## UI notes
+
+- CodeMirror injects its base theme at runtime *after* `app.css`, so anything
+ that must win (content padding for the results column, cursor color) lives
+ in an `EditorView.theme(...)` extension, not the stylesheet.
+- The results column is an absolutely-positioned overlay; each result is
+ placed with `view.lineBlockAt(...)` screen coordinates and re-rendered on
+ scroll/geometry changes. Click-to-copy reads `data-value`.
+- Engine evaluation is synchronous on every keystroke — a full sheet parse
+ is well under a millisecond, so there's no debounce or worker.
+
+## Storage
+
+- `settings.json` always sits in `%APPDATA%/app.summarum.calc`.
+- `documents.json` (all sheets) + `backups/` live either there or in the
+ user-chosen data folder (the path is stored in settings).
+- Daily snapshot at startup (`backups/documents-YYYY-MM-DD.json`, keep 14);
+ deleted sheets become `.numi` files under `backups/deleted/` and are pruned
+ by age. Saving is debounced at 400 ms.
+
+## Tests
+
+`npm test` runs ~80 vitest cases over the engine (`src/engine/*.test.ts`):
+every expression class, both languages, deterministic injected rates.
+UI is exercised manually; the engine is where the complexity lives.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fea9638
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,6 @@
+[Historical MIT license text below has been superseded — see the current LICENSE file for the license actually in effect: PolyForm Noncommercial 1.0.0.]
+
+Copyright (c) 2026 Joseph Munoz
+
+[Grant text removed — superseded by PolyForm Noncommercial 1.0.0, see current LICENSE file]
+[Remaining historical MIT boilerplate removed — superseded by PolyForm Noncommercial 1.0.0; see current LICENSE file for the license actually in effect.]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..73667ff
--- /dev/null
+++ b/README.md
@@ -0,0 +1,136 @@
+# Summarum
+
+**A calculator that works like a notepad.** Type what you're thinking — every line
+gets a live answer in the right column. No buttons, no formula syntax, just text.
+
+```
+# Trip to Lisbon
+flights $420
+hotel $95 * 4 nights
+food 30€ a day * 4 days and museums 25€
+total → $1,066.97
+
+5 inches in cm → 12.7 cm
+100 km/h in mph → 62.14 mph
+$2000 in rubles → 144,060 ₽
+20% off 1500 → 1,200
+today + 6 weeks → 22 July 2026
+time in Tokyo → 14:25
+```
+
+Works in **English and Russian** at the same time: `5 метров в см`, `20$ в евро`,
+`сумма` — no language switching.
+
+## Install
+
+Grab the latest `Summarum_*_x64-setup.exe` from [Releases](../../releases),
+run it, done. The whole app is about 2 MB.
+
+Requirements:
+
+- **Windows 10 (1803+) or Windows 11**, 64-bit.
+- **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
+ already part of Windows 11 and most updated Windows 10 systems; if it's
+ missing, the installer downloads it automatically.
+- Internet connection is only needed for live currency rates — everything
+ else works offline (rates fall back to a cached/bundled snapshot).
+
+## What it understands
+
+**Plain math** — `2 + 2`, `8 / (45 - 20%)`, parentheses, `^`, `sqrt`, `sin`,
+factorials. Decimal-exact: `0.1 + 0.2` is `0.3`, not `0.30000000000000004`.
+
+**Words as operators** — `12 plus 4`, `20 divided by 4`, `5 и 3`. Text around
+the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
+
+**Units** — length, weight, volume, area, temperature, time, angles, data
+(`1 GiB in MB`), CSS px/em, speed, pressure, energy, power, frequency, fuel:
+
+```
+6 feet in meters 1 atm in mmHg 8 л/100км in mpg
+2 hours 30 min in min 150 hp in kW 32 px in em
+```
+
+**Money** — ~150 currencies and the top-20 crypto with live rates
+(refreshed hourly, cached for offline). `$100 + 20%`, `5 eth in USD`,
+`9800 рублей в долларах`.
+
+**Percents the human way** — `20% of 80`, `20% off 1500`, `5 as a % of 25`,
+`20% of what is 30`.
+
+**Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York`,
+`now in unix`, `1750000000 as date`.
+
+**Programmer stuff** — `0xFF & 0x0F`, `1 << 4`, `255 in hex`, `in binary`,
+plus `in fraction` (0.75 → 3/4) and `in roman` (2026 → MMXXVI).
+
+**Sheets that calculate** — variables (`rent = $500`), running totals
+(`sum`, `avg`, `prev`), `#` headers, `//` comments. The status bar always shows
+the sheet total; select a few lines to see just their sum.
+
+## Handy things
+
+- **Ctrl+Alt+N** (configurable — the settings field records keys you press)
+ shows/hides the window from anywhere. Closing hides to tray.
+- **Click a result** to copy it. Copying a line copies it *with* the answer
+ (`rent * 3 = $1,500`). **Ctrl+Shift+C** copies the whole sheet with answers.
+- **Drag the divider** to resize the results column.
+- **Drop a `.numi` or `.txt` file** into the window to open it as a sheet.
+- **Backups are automatic**: a daily snapshot of all sheets (last 14 kept), and
+ deleted sheets sit in a bin for 14 days (configurable) — restore one by
+ dragging the file back in. Settings → Backups opens the folder.
+- **Data folder is configurable** — point it at OneDrive/Dropbox and your
+ sheets follow you between machines.
+- Light/dark theme, en/ru interface, configurable precision and separators.
+
+## Extensions
+
+Drop a `.js` file into the extensions folder (Settings → Extensions folder)
+and restart. The API is compatible with Numi extensions:
+
+```js
+numi.setVariable("vat", { double: 20 });
+numi.addUnit({ id: "floor", phrases: "floor,floors", baseUnitId: "meter",
+ format: "fl", ratio: 3 });
+numi.addFunction({ id: "hyp", phrases: "hyp" },
+ (v) => ({ double: Math.hypot(v[0].double, v[1].double) }));
+```
+
+Then `5 floors in meters` → `15 m` and `hyp(3; 4)` → `5`.
+
+## Bugs and ideas
+
+Open an [issue](../../issues). A line of text and what you expected is enough.
+
+## For developers
+
+Engine internals, project layout and how to add units/currencies:
+see [ARCHITECTURE.md](ARCHITECTURE.md).
+
+```bash
+npm install
+npm test # engine tests
+npm run tauri dev # run the app
+npm run tauri build # build the installer
+```
+
+To build from source you need:
+
+- [Node.js](https://nodejs.org/) 20 or newer
+- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
+- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
+ with the "Desktop development with C++" workload (the MSVC linker)
+- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
+ if anything refuses to compile
+
+Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
+
+## Credits
+
+Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
+if you're on macOS, buy it. Summarum is an independent project, built from
+scratch for Windows.
+
+## License
+
+[MIT](LICENSE)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..11d7326
--- /dev/null
+++ b/index.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+ Summarum
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7c389b4
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2064 @@
+{
+ "name": "numi-windows",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "numi-windows",
+ "version": "1.0.0",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.0",
+ "@codemirror/commands": "^6.8.0",
+ "@codemirror/language": "^6.10.0",
+ "@codemirror/state": "^6.5.0",
+ "@codemirror/view": "^6.36.0",
+ "@lezer/highlight": "^1.2.0",
+ "@tauri-apps/api": "^2.5.0",
+ "@tauri-apps/plugin-autostart": "^2.3.0",
+ "@tauri-apps/plugin-dialog": "^2.7.1",
+ "@tauri-apps/plugin-global-shortcut": "^2.2.0",
+ "@tauri-apps/plugin-opener": "^2.2.0",
+ "decimal.js": "^10.4.3"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^2.5.0",
+ "@types/node": "^25.9.2",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0",
+ "vitest": "^3.0.0"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
+ "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.6.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
+ "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
+ "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
+ "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
+ "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
+ "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
+ "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
+ "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
+ "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
+ "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
+ "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
+ "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
+ "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
+ "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
+ "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
+ "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
+ "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
+ "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
+ "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
+ "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
+ "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
+ "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
+ "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
+ "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
+ "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
+ "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tauri-apps/api": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
+ "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
+ "license": "Apache-2.0 OR MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ }
+ },
+ "node_modules/@tauri-apps/cli": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
+ "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "bin": {
+ "tauri": "tauri.js"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/tauri"
+ },
+ "optionalDependencies": {
+ "@tauri-apps/cli-darwin-arm64": "2.11.2",
+ "@tauri-apps/cli-darwin-x64": "2.11.2",
+ "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
+ "@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
+ "@tauri-apps/cli-linux-arm64-musl": "2.11.2",
+ "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
+ "@tauri-apps/cli-linux-x64-gnu": "2.11.2",
+ "@tauri-apps/cli-linux-x64-musl": "2.11.2",
+ "@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
+ "@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
+ "@tauri-apps/cli-win32-x64-msvc": "2.11.2"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-arm64": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
+ "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-darwin-x64": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
+ "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
+ "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
+ "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-arm64-musl": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
+ "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
+ "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-gnu": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
+ "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-linux-x64-musl": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
+ "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
+ "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
+ "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/cli-win32-x64-msvc": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
+ "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 OR MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-autostart": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
+ "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.8.0"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-dialog": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
+ "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-global-shortcut": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz",
+ "integrity": "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
+ "node_modules/@tauri-apps/plugin-opener": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
+ "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@tauri-apps/api": "^2.11.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
+ "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
+ "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.6",
+ "@vitest/utils": "3.2.6",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
+ "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.6",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
+ "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
+ "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.6",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
+ "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.6",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
+ "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
+ "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.6",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
+ "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.61.1",
+ "@rollup/rollup-android-arm64": "4.61.1",
+ "@rollup/rollup-darwin-arm64": "4.61.1",
+ "@rollup/rollup-darwin-x64": "4.61.1",
+ "@rollup/rollup-freebsd-arm64": "4.61.1",
+ "@rollup/rollup-freebsd-x64": "4.61.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.61.1",
+ "@rollup/rollup-linux-arm64-musl": "4.61.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.61.1",
+ "@rollup/rollup-linux-loong64-musl": "4.61.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.61.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.61.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.61.1",
+ "@rollup/rollup-linux-x64-gnu": "4.61.1",
+ "@rollup/rollup-linux-x64-musl": "4.61.1",
+ "@rollup/rollup-openbsd-x64": "4.61.1",
+ "@rollup/rollup-openharmony-arm64": "4.61.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.61.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.61.1",
+ "@rollup/rollup-win32-x64-gnu": "4.61.1",
+ "@rollup/rollup-win32-x64-msvc": "4.61.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
+ "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.6",
+ "@vitest/mocker": "3.2.6",
+ "@vitest/pretty-format": "^3.2.6",
+ "@vitest/runner": "3.2.6",
+ "@vitest/snapshot": "3.2.6",
+ "@vitest/spy": "3.2.6",
+ "@vitest/utils": "3.2.6",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.6",
+ "@vitest/ui": "3.2.6",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ca5d44e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "summarum",
+ "private": true,
+ "version": "1.0.0",
+ "description": "Summarum — natural language calculator notebook for Windows",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "icons": "node scripts/gen-icon.ts",
+ "tauri": "tauri"
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.0",
+ "@codemirror/commands": "^6.8.0",
+ "@codemirror/language": "^6.10.0",
+ "@codemirror/state": "^6.5.0",
+ "@codemirror/view": "^6.36.0",
+ "@lezer/highlight": "^1.2.0",
+ "@tauri-apps/api": "^2.5.0",
+ "@tauri-apps/plugin-autostart": "^2.3.0",
+ "@tauri-apps/plugin-dialog": "^2.7.1",
+ "@tauri-apps/plugin-global-shortcut": "^2.2.0",
+ "@tauri-apps/plugin-opener": "^2.2.0",
+ "decimal.js": "^10.4.3"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^2.5.0",
+ "@types/node": "^25.9.2",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0",
+ "vitest": "^3.0.0"
+ }
+}
diff --git a/scripts/gen-icon.ts b/scripts/gen-icon.ts
new file mode 100644
index 0000000..04e878a
--- /dev/null
+++ b/scripts/gen-icon.ts
@@ -0,0 +1,156 @@
+// Generates the icon set with no dependencies: a minimal PNG encoder draws
+// a dark rounded square with an orange equals sign. Usage: node scripts/gen-icon.ts
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as zlib from "node:zlib";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const outDir = path.join(root, "src-tauri/icons");
+
+// ---- tiny PNG encoder (RGBA, no filters)
+
+const CRC_TABLE = new Int32Array(256).map((_, n) => {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ return c;
+});
+
+function crc32(buf: Buffer): number {
+ let c = ~0;
+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
+ return ~c >>> 0;
+}
+
+function chunk(type: string, data: Buffer): Buffer {
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(data.length);
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(body));
+ return Buffer.concat([len, body, crc]);
+}
+
+function encodePng(size: number, rgba: Buffer): Buffer {
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 6; // RGBA
+ // scanlines with filter byte 0
+ const raw = Buffer.alloc(size * (size * 4 + 1));
+ for (let y = 0; y < size; y++) {
+ raw[y * (size * 4 + 1)] = 0;
+ rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
+ }
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk("IHDR", ihdr),
+ chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
+ chunk("IEND", Buffer.alloc(0)),
+ ]);
+}
+
+// ---- drawing: dark rounded square, orange "equals" bars
+// 4x supersampling for smooth edges
+
+const BG = [0x24, 0x25, 0x2a]; // dark slate
+const FG = [0xe8, 0x92, 0x3c]; // summarum orange
+
+function draw(size: number): Buffer {
+ const ss = 4;
+ const S = size * ss;
+ const corner = S * 0.22;
+ const px = Buffer.alloc(size * size * 4);
+
+ const insideRoundRect = (x: number, y: number): boolean => {
+ const r = corner;
+ const cx = Math.max(r, Math.min(S - r, x));
+ const cy = Math.max(r, Math.min(S - r, y));
+ return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
+ };
+
+ // equals bars geometry (relative to S)
+ const barW = S * 0.52;
+ const barH = S * 0.115;
+ const barR = barH / 2;
+ const x0 = (S - barW) / 2;
+ const topY = S * 0.355;
+ const botY = S * 0.53;
+
+ const insideBar = (x: number, y: number, by: number): boolean => {
+ if (x < x0 || x > x0 + barW || y < by || y > by + barH) return false;
+ const cx = Math.max(x0 + barR, Math.min(x0 + barW - barR, x));
+ const cy = by + barR;
+ return (x - cx) ** 2 + (y - cy) ** 2 <= barR * barR || (x >= x0 + barR && x <= x0 + barW - barR);
+ };
+
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ let bgHits = 0;
+ let fgHits = 0;
+ for (let sy = 0; sy < ss; sy++) {
+ for (let sx = 0; sx < ss; sx++) {
+ const X = x * ss + sx + 0.5;
+ const Y = y * ss + sy + 0.5;
+ if (!insideRoundRect(X, Y)) continue;
+ bgHits++;
+ if (insideBar(X, Y, topY) || insideBar(X, Y, botY)) fgHits++;
+ }
+ }
+ const total = ss * ss;
+ const alpha = Math.round((bgHits / total) * 255);
+ const fgRatio = bgHits > 0 ? fgHits / bgHits : 0;
+ const i = (y * size + x) * 4;
+ px[i] = Math.round(BG[0] + (FG[0] - BG[0]) * fgRatio);
+ px[i + 1] = Math.round(BG[1] + (FG[1] - BG[1]) * fgRatio);
+ px[i + 2] = Math.round(BG[2] + (FG[2] - BG[2]) * fgRatio);
+ px[i + 3] = alpha;
+ }
+ }
+ return px;
+}
+
+fs.mkdirSync(outDir, { recursive: true });
+const pngs = new Map();
+for (const size of [16, 32, 48, 64, 128, 256, 512]) {
+ pngs.set(size, encodePng(size, draw(size)));
+}
+
+const files: Array<[string, number]> = [
+ ["32x32.png", 32],
+ ["128x128.png", 128],
+ ["128x128@2x.png", 256],
+ ["icon.png", 512],
+];
+for (const [name, size] of files) {
+ fs.writeFileSync(path.join(outDir, name), pngs.get(size)!);
+ console.log(`${name} (${size}px)`);
+}
+
+// .ico with PNG-compressed entries
+function buildIco(sizes: number[]): Buffer {
+ const header = Buffer.alloc(6);
+ header.writeUInt16LE(1, 2);
+ header.writeUInt16LE(sizes.length, 4);
+ const dirs: Buffer[] = [];
+ const blobs: Buffer[] = [];
+ let offset = 6 + sizes.length * 16;
+ for (const s of sizes) {
+ const data = pngs.get(s)!;
+ const dir = Buffer.alloc(16);
+ dir.writeUInt8(s >= 256 ? 0 : s, 0);
+ dir.writeUInt8(s >= 256 ? 0 : s, 1);
+ dir.writeUInt16LE(1, 4);
+ dir.writeUInt16LE(32, 6);
+ dir.writeUInt32LE(data.length, 8);
+ dir.writeUInt32LE(offset, 12);
+ offset += data.length;
+ dirs.push(dir);
+ blobs.push(data);
+ }
+ return Buffer.concat([header, ...dirs, ...blobs]);
+}
+
+fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
+console.log("icon.ico (6 sizes)");
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
new file mode 100644
index 0000000..5d861e0
--- /dev/null
+++ b/src-tauri/Cargo.lock
@@ -0,0 +1,5745 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "auto-launch"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
+dependencies = [
+ "dirs 4.0.0",
+ "thiserror 1.0.69",
+ "winreg 0.10.1",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "brotli"
+version = "8.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.13.0",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
+dependencies = [
+ "serde",
+ "toml 0.9.12+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation 0.10.1",
+ "core-graphics-types",
+ "foreign-types 0.5.0",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation 0.10.1",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "ctor"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
+dependencies = [
+ "ctor-proc-macro",
+ "dtor",
+]
+
+[[package]]
+name = "ctor-proc-macro"
+version = "0.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
+dependencies = [
+ "dirs-sys 0.3.7",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys 0.5.0",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
+dependencies = [
+ "libc",
+ "redox_users 0.4.6",
+ "winapi",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users 0.5.2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dom_query"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
+dependencies = [
+ "bit-set",
+ "cssparser",
+ "foldhash 0.2.0",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dtor"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4"
+dependencies = [
+ "dtor-proc-macro",
+]
+
+[[package]]
+name = "dtor-proc-macro"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 1.1.2+spec-1.1.0",
+ "vswhom",
+ "winreg 0.55.0",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared 0.1.1",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared 0.3.1",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.13.0",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "global-hotkey"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
+dependencies = [
+ "crossbeam-channel",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.59.0",
+ "x11rb",
+ "xkeysym",
+]
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
+dependencies = [
+ "byteorder",
+ "png 0.17.16",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.13.0",
+ "serde",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "markup5ever"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "muda"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.0",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.0",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.0",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "open"
+version = "5.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
+dependencies = [
+ "dunce",
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
+[[package]]
+name = "openssl"
+version = "0.10.80"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "foreign-types 0.3.2",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.116"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.12+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "rfd"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
+dependencies = [
+ "block2",
+ "dispatch2",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "selectors"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
+dependencies = [
+ "bitflags 2.13.0",
+ "cssparser",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "summarum"
+version = "1.0.0"
+dependencies = [
+ "chrono",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-autostart",
+ "tauri-plugin-dialog",
+ "tauri-plugin-global-shortcut",
+ "tauri-plugin-opener",
+ "tauri-plugin-single-instance",
+]
+
+[[package]]
+name = "swift-rs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.35.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "core-foundation 0.10.1",
+ "core-graphics",
+ "crossbeam-channel",
+ "dbus",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "once_cell",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs 6.0.0",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest 0.13.4",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs 6.0.0",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
+dependencies = [
+ "base64 0.22.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png 0.17.16",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.117",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin"
+version = "2.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
+dependencies = [
+ "anyhow",
+ "glob",
+ "plist",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-plugin-autostart"
+version = "2.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
+dependencies = [
+ "auto-launch",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tauri-plugin-dialog"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
+dependencies = [
+ "log",
+ "raw-window-handle",
+ "rfd",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "tauri-plugin-fs",
+ "thiserror 2.0.18",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-fs"
+version = "2.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
+dependencies = [
+ "anyhow",
+ "dunce",
+ "glob",
+ "log",
+ "objc2-foundation",
+ "percent-encoding",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "toml 1.1.2+spec-1.1.0",
+ "url",
+]
+
+[[package]]
+name = "tauri-plugin-global-shortcut"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
+dependencies = [
+ "global-hotkey",
+ "log",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "tauri-plugin-opener"
+version = "2.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29"
+dependencies = [
+ "dunce",
+ "glob",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "open",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "url",
+ "windows",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "thiserror 2.0.18",
+ "tracing",
+ "windows-sys 0.60.2",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dom_query",
+ "dunce",
+ "glob",
+ "http",
+ "infer",
+ "json-patch",
+ "log",
+ "memchr",
+ "phf",
+ "plist",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.18",
+ "toml 1.1.2+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 1.1.2+spec-1.1.0",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
+dependencies = [
+ "new_debug_unreachable",
+ "utf-8",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.0",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773"
+dependencies = [
+ "crossbeam-channel",
+ "dirs 6.0.0",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.18",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unic-char-property"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
+dependencies = [
+ "unic-char-range",
+]
+
+[[package]]
+name = "unic-char-range"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
+
+[[package]]
+name = "unic-common"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
+
+[[package]]
+name = "unic-ucd-ident"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
+dependencies = [
+ "unic-char-property",
+ "unic-char-range",
+ "unic-ucd-version",
+]
+
+[[package]]
+name = "unic-ucd-version"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
+dependencies = [
+ "unic-common",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlpattern"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
+dependencies = [
+ "regex",
+ "serde",
+ "unic-ucd-ident",
+ "url",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.14.0",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.13.0",
+ "hashbrown 0.15.5",
+ "indexmap 2.14.0",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-implement",
+ "windows-interface",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.38.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
+dependencies = [
+ "thiserror 2.0.18",
+ "windows",
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "windows-sys 0.59.0",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.61.2",
+ "windows-future",
+ "windows-link 0.1.3",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+
+[[package]]
+name = "winnow"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "winreg"
+version = "0.55.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck 0.5.0",
+ "indexmap 2.14.0",
+ "prettyplease",
+ "syn 2.0.117",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags 2.13.0",
+ "indexmap 2.14.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.14.0",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wry"
+version = "0.55.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs 6.0.0",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.18",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core 0.61.2",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.3",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
+dependencies = [
+ "serde",
+ "winnow 1.0.3",
+ "zvariant",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zvariant"
+version = "5.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.3",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 2.0.117",
+ "winnow 1.0.3",
+]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
new file mode 100644
index 0000000..9a49c76
--- /dev/null
+++ b/src-tauri/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "summarum"
+version = "1.0.0"
+description = "Summarum — natural language calculator notebook for Windows"
+edition = "2021"
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+tauri = { version = "2", features = ["tray-icon"] }
+tauri-plugin-global-shortcut = "2"
+tauri-plugin-autostart = "2"
+tauri-plugin-single-instance = "2"
+tauri-plugin-opener = "2"
+tauri-plugin-dialog = "2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+reqwest = { version = "0.12", features = ["json"] }
+chrono = "0.4"
+
+[profile.release]
+strip = true
+lto = true
+opt-level = "s"
+codegen-units = 1
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
new file mode 100644
index 0000000..ae835de
--- /dev/null
+++ b/src-tauri/capabilities/default.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "default",
+ "description": "Main window capabilities",
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "core:window:allow-show",
+ "core:window:allow-hide",
+ "core:window:allow-set-focus",
+ "core:window:allow-is-visible",
+ "core:window:allow-is-focused",
+ "core:window:allow-close",
+ "core:window:allow-minimize",
+ "core:window:allow-start-dragging",
+ "global-shortcut:allow-register",
+ "global-shortcut:allow-unregister",
+ "global-shortcut:allow-is-registered",
+ "autostart:allow-enable",
+ "autostart:allow-disable",
+ "autostart:allow-is-enabled",
+ "opener:default",
+ "dialog:default"
+ ]
+}
diff --git a/src-tauri/extension-sample.js b/src-tauri/extension-sample.js
new file mode 100644
index 0000000..26dc6b4
--- /dev/null
+++ b/src-tauri/extension-sample.js
@@ -0,0 +1,21 @@
+// Summarum extension example.
+// Drop .js files into this folder and restart the app.
+// The API is compatible with Numi extensions: numi.setVariable,
+// numi.addUnit, numi.addFunction.
+
+// A global variable usable on any sheet, e.g. `salary * vat`
+// numi.setVariable("vat", { "double": 20 });
+
+// A custom unit: 1 floor = 3 meters, so `5 floors in meters` works
+// numi.addUnit({
+// "id": "floor",
+// "phrases": "floor, floors, этаж, этажа, этажей",
+// "baseUnitId": "meter",
+// "format": "fl",
+// "ratio": 3,
+// });
+
+// A custom function: hyp(3; 4) -> 5
+// numi.addFunction({ "id": "hyp", "phrases": "hyp" }, function (values) {
+// return { "double": Math.hypot(values[0].double, values[1].double) };
+// });
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
new file mode 100644
index 0000000000000000000000000000000000000000..793ee17fd53d11f45df1695e7ddbe85a59e88b17
GIT binary patch
literal 971
zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV4mdZ;uunK>+KxdzAFYI4aF@o
zN|}e0n#2TlGimDbdO5qhx;J0Cs3_i1wq&mZ-v;&tJ_i}wq4@bg_w+DN;p4D>&TzuRtE4XSa>kYvd4{oki
zbjZ>UV%o#IYvtC1SDw7*eD&;x|HK5ZPnuu0#VRbD&gF1E+(2lKaD%1&x>*U+7(#w~
zg|RQc_LD`&Z|NwkU>M+3tymI<$>Kly+3xz~Yg!5@D=dw)GSU1CKns{!v1*?-M9
zIInDBwb`b6Cek79OS$~KmEQZd%-r0-e)(YSXQf|9yrpaTe;>ThKA~5TGsvf<;KSio
z0fYw=Bu^Mk=v6Rly1|vEJ>#$fU$X_@Gm9DAiZL7yy(Gd9N&MR>(#61W;IM#P+Xs`L
z<%u?YPYX59AFh-;$FB6lfn|QjLt(=jm9BmlMsX(wMnX`bfRT^INta!4fwDl>OP7Xe
z4l9<5GDrRQTVxM*_1%qF=g)aSGcjd(
z-SSU0ehe!`zO&?ie$yWMUxqP4%5B?|-7XAnO&-<%WEkhLTw%FA>+@yJopZGqRyX&C
z|Mup5@Qw3^4p;Hy<+Em=_hymElMYsB3w$xt`|}TdrX9Bq7${Y|{+RYEX8$w>=d9|}
zErE%5@7>#VTE=_B5(a0l>f1ZEifSq!NwwXx*)S)^NBsQNeBI9yUQ>$>~^fItusPE=TMcNL|BT;%{^fx-%kMW|vTsIh9r3lbV2IpMMa
zWmklDxu^&h6%~z+7la~7(N(azL|heA;0U6IYc{YX5J(#LL-#|!b^3mIf6qKK|Cx85
zdH!!v-1-PBb4POku!@RgZv;S$5&^oY5nB({?*gEmk79>x%6v7}yL!JzJacD%+}PP|I@&n-RC!F79P
z>p8wLU{W@Kcnd{Z)Z>T
z%;>k}ohjcBdR#E{Jm+#U8Or+bFU?VfoTF88ef>4u6Bj3kyx%a1u_@DgPj?sxGc!FN
z|Df&}H{r=g*B*$uj
zpO)7;crW2F^+qWgSNU8*yW+U1iN+oNs%_hOKhqi>W#R9dYwOFSVTJDJ+P`TSm#)&t
z`)t>n{vnxN&mraw=35x`66LDGzOmk&qI%s;Io^2i;|2=3DtO0Sl$?~vt-E=*`wJxF
zzJ8lvMRbm$!>#So7)w58ql0ud`}(ufN%1MliE#t}zQUX8I=DLf2h#r;s_q
z_K868SE$$F)vV|nITS_XJ6h5zt7(lHk)w}O=}^s*-Z3;(|6rjRaadPtc`TViN`t9d
z+fTRNR@GD~Vp=|W*6$9x^sM%y^5(|*23=T_4i%h4$-laKyxtKHqcakGf?hF~CodEC
zX(aLwCca~|0Y45Cn5F}F>|l$CMZVxlgm}yi0gTQ|hzV1zexdjS(qIT9=fNFVDt3(-
zP7k!MK)v`Ztggup=q)xFFakk1?v&DelLqJ_U;zH9Uey1apvuD&r2_R2znt70D>a-F
zX3c9Rg0~tb^$}}I$CH}*N|*Cl%1V3U3Qg}C?8*^kYTn>0*v;k2YI8B{o0{FZlVrN&
zrXLR)K~+Ul%r>lu)w{Q|1?DD3U#3|WbGw2xqZaZf%TFNI@y!~Pp>;J3Xp6**tpZaj
zRxcCke#Gc2gD5CNq11j(A)J&+3|J(pVAv8K4Vw6VR}9wa2lo=dR!vV;N~*VXd9xs6
zemlPg=zwB?5PBFdIou
zX*H5Y5iDbriBQH<9jDcYk^6g1UlIOexHoI}KrEa!*b|dT%3Z_6T9hhZ5E7s_CUj@)
z!h|e6fm_QnoXZ+n%p|UamADsuVfX&-{qpfz@hZsbw5}Uks(u^L3V<44h5_+Opk(N;
z{EzWTY@6RYfqTtAx_Ko5D;hlQA6ANko2a|7(RLazEACUjsM`HU-c
zj4^VsMte{pH)KXAf(hv5prC4aUrHSgKWxH_^@*bH8To;V@x{4|c^N+e-dW
zgcRQI5!LbJuZfVmzQPm+wY(ZZ=#TgQa~s8;AYnN>*SCzZ-(!XauTL#wyk|<
z#;@~|!N-4F2q?h5wmCO=*4d@%!}c@EzH?c-NsHf7TKeB9{m;$_
z<$y92k1Y2D3nm)+M>bnnG1}6TVU$K-BZd*QWa}N@xSPjjD`ZYY
zkp(sU+cZqP
zMbv0T#3FH8jzNc#@nAdm($ETt)&$Da^Ud)7R%jRW%4D(NY>&=V^{#DgG$Z&_Y%<88
zGp#uk!utFVWypbTutVD_GKxKn^!}NZse+dmsrY&i$k0wdNSd=BNnFeE^5)?WWzRZYbXhqUkwlHzcDsTZ^}wnY3H~qL&zn1YT70^7
dP2c@pgtY(Sc*}E5^+zzCsPOgdtD)T7e*+D6K2`t#
literal 0
HcmV?d00001
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
new file mode 100644
index 0000000000000000000000000000000000000000..eedb8e2c7b5ef3159a73bc8cdc1e74a40f690aa8
GIT binary patch
literal 249
zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ1D-C9Ar-fh0|KumIUHhg*yZRN
z`g%{-s%!OHk3R=&W}WcXWt$C1%$8Tj?fCnJWeY778u`Q)KW3T1%<|$%agL(m0fye`
z|Cw8)B{HTw=gTe~DWM4fFw$PQ
literal 0
HcmV?d00001
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..85aff5b7713d67e8074907cebde72b4a20c65aff
GIT binary patch
literal 4273
zcmb_f2~-nFx32C461EuDAZt)z65N8!ARtS#2nZaPCoy>)82U)8Ou1^@!G
zfJ_D$Qb9QYAP~z`>JNPila(L<7>pnK7EG3f0YH!+dL^d&-~d3;ANpfVCdgowur$|R
zF9CoKA^?Xy*EuW6Ysq6~r8O>&>oLT71Z1Kt_SF|i{V>?K#?fwL>>GbiuNS+pERW@e
zs=>Xo_Qz$&O|uC6IBU5h~7YkUHp2)a=UE*@+815+!NM4@je=
zb@HO@HdVAsZ)o~o)?DP=rl2+-QD5$*
zX^GSCW2Moh=F}9D#<@OxJ`O+dF(TB!fD4Y~Npb1gn!*3#9a@*KhzqvRJZ8!4gz&oA
z1N-=_sFc^zk$#Oue%X52J2Q2ddoK&5_ZMkrG9D^k(6@n6C7skLjj(5)V|01o!1<^IdA
zZoqu`JIApZ2aNBIZ-J-S*mUasaD1fDnv{x=GlaD5!)BN68=rLAyUM{os87ZU%{Wgyw%b$h7TG#w^HKQZ;R#sYEvW|
z)q279?m!r*sad97GPU0OeT_0zulj4l2TN2>lowVg=Og2i@??uJ-PofD7*}Qb%d1oE
zd~C*cm!VHTs{n|My{Ht{F|)B+_L$GPVyS+=tm`uP68xtAYXgm-&5hEp!FiI*(7xyg
zB*dZ%%tWe;ewHbU%Uo-*b)|`+`N*fKjWP7hsY`M71S(XLN1ib{XzTQT`3g}ve#U>@
zDDx`?vIb!<`bz1jYMpcTQ&k?*ut
z?g{U~Xbu09)*=(vdx+Y>&op{qVK{|AarC;o&WanY@l`fDl~7$n9R9@$-M#Eu_RD?8
z7%NsBuecqMm%+&vsS`xOph2RU%g7$w9WL&Qh8pWiKXHk@KrCo^R>ZYYxi8G
zzlnSlD@MQ1REv$@V*qeDf6mk?>!Y0vH8dh!XThH41*sb3+c15plB-Djx=b=b0$tlzyQ`h}7>MCQQA
z-7NV}EP2oNzBFCs$~u`Yy0KK(C@abX_=|wka(RQQ*?tb
ztbQ>hXV<0#Q-K`<{^3DS#wI1bV8x5COD83ku4TV=IN@WFxNj9)M9>f_!?XW}mzO_BE0UiYa39SX^b^1oC&u{R3bCp@pKs|Ow=dElqM!kvJ(TxRdjGR4cdxQ&R^aySQJ!Xnj|@Xeq$^45
zlIy%KV|Byui8r!yB1XUXqFdwjf?AaD{BDw9!Sp>h?WB3)yS;?$Bg%5?-P8B;TQbOh
zImn#$9a$CrH2l+o|mHSzd
z`on3hEpd9!2n$3A{>-8GswRnxGLJbz6npF#{fCFTA0pxHmlBjq7Lr2-9noB*;=DSf
z1()VJ;c0T|+gz0qa}~uXmuIR!fhe?jibX@#BAs6IWSF+d>*?P9BpPbK2x0icWol_5
znN5-IzzN!Yhvg1JAj1f$1!N4XFMXi~rR(0!JlehWs)rnx?iWy=Tt1$-4qQ|d;==^P
z>`hV~XsedfjgPk-fm5nWf+b1kc&y`AT?QOn6W-pc
zQ>QPyx7(UiYZ4mjPHBA+wp5_$dh!5w-`|Hac1giV{2t0X4pdZP0F%(OGhO1pezk;?~7W>A?)XN8+xxto8r0xxrQ_5&Hn7a_Qmt}r}SqQgbsL|Ufi
z9Dl?#e6B_xUB61u^Z}yxb)WY5_+*$L%+S%@U1_U%^nr9%7|PIjN@!`{HF$ub
zafs)uM6wVCHTJb^j=y~!09WQ|UJF5AiHLqYM-{GKg@Gkz_F5I!3;R9Yc-g4W3nKfJT5g5BAwLLOyt{edqq2np*!Ze2J=7YBhrQ){-x67fFgpz7(U!3Y0Yqqzk
zG{W(ck{^y)r8vQi2onRh+9X`t&5s1{xu0dsM+hG*d?|n;`{9;4*v>NGy=1bJ5%=17Fg(jf$7Ds4c`2Wv%Tfp4
zEHDBFe8&Mv6sAcu)fXGpy0YBhg2YOUXe5E-4HXVcOhZ0Km)x(km%d$`?6w%?+|0hhP=UI
z`4kX3F}-^?kD`|vdCd9?%s)IqX{H`F=-%7P0W)L6ufi0wm>t%_VFm8fMaLoK4Glt=
zEYg>L5FJh-?}(ShQ!AR1#m}h3W!5;D+%_m$j6<~ty~#k8l}}bfX_ti?diANmM$)$z
z1&;j5i2`=<_6}165IyS`Qv!$p+w5T!VEZeEOm%Pxz@C4U>g&Z1oz>G&iC*Ywx$KWL
zkDOu|?G%n|)~i$*stP+Prx*&u#V;hEV8P^ev2UXpIn`Cza~OHH+SDNUg9kV()j$Q%
zt~=7Pl`x)5vRfs=7`io5KGqbunOPbnJ)iJpJ_WtvkZp8$iTbBI_wq(dQ^5mH39%*BclRzPJns#-|@cuf{0J{-rb4QKzZ)O
zLXj$PPSw_oo|Quas(d$364V-d&WzRs`2aILjU0(R(asxZ+r6Plh6+|?P}i!Q?>nob
zSNPfQ%wW&?gkYY+Uo0*7zbyURmM^&dgZ79^9vvu9VEb)EfS<>Gmw{$~q61URP
z`4EYFPQh(+QI;%^-T~z2?Z#j;Ozo0Ktbq}1(1mCIg`Xg5mr!~eGLP}1rVFB={00WO
zInoCV6Hw|G=@&SA$1QWu>%UFsH)*4X74WNzLgkS#Y$L6A@hrR4uPF@$Ii?i(%*f~%
zPL%@s2%#m8hiw2HZICfK+iM1H$U3BKXWdjnJs?k!uT%{4HUPDmSJFnXRT+hYBr$SG
zZzFgZ-`&*Y0dzFrslx7+jW^}c7(7*AEfvd_9I!K3yy*nAeM
z6_}VgWghvmzx(EFA&DDe!|k9(hCQl!6J=$)2s}EhKstS3JiBJ54VYUK8~n%H!Z{wm
z5vDh9IU%D}N!i
zIc6q})j1XqhQtJ;%}TV$&8&`>@yT@PmiQtw#6h_rj8_odUzM~E+Pc!0VH$+|-t@dx
xPn?i+5!x!0^Ha~58vn4I!$uy9ziw%L%`xi|*B?E{f*yX;s-v$c{$Ky-{{^7~uJr%_
literal 0
HcmV?d00001
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..60c2e497fb0a7d64d90cbd5227ea756826944535
GIT binary patch
literal 4597
zcmb7Id0dlM);{;mDq8{~1QZj*5tPNCMMc59Dhk#Oog%1EqQMrcvWN>9BrmR2QK{Gw
zsY+^F#SNoaWfa3ZRu+XKE(I#F3AiCBAhMHXZuINV-#6cU)6QS__2a;}p}|6WxiCFA)Yb2j
z)q~tG4+^A?g}!xpa?8?VL$^hNBYUrCo&})>o^I>Pj~%8$5(R4>yyT{8i!XN0nKNSM
z!U{un%AQjR*E_28g;T~4=z5=aqJ7o=Q~h7NHt16mwqk!8tRE1&6I*UH>aZEM{5<Joo7QzmP4k7A#|>L+nHOK-!z*s~cvqCKb-~ecpZEoevx4_HR28
z@;C#!avWrr1bpDg?sH;;t+ET4nl(D73Plj!@{(fp7?|d^ydN+zO165b`=)2!ALQDOLK%`5Qz^9g6
zYUfM2hDWDITXwR@g0!w>k9p+R*IAPZrE!zN(&U8l>Q1~~&3F{hC$Hs^gKOAa%7WK<
z@urX(_1b=t9XRwOX}S_T?v*RV;2bv+&UJ8TAB1#UBv8ZRQc>$~v~iTrT8L`|y+Q(B
zHukX0O1}$KgE!6BJk%5R3rA3`*tkQ(18VZo7XE@Vkygs1}A>x6KDG0qY!ji3^X
z3qah*AotmZ*1AfJI%roI9x~`RYDkEBR>lrDEEnQsYxYy$JtUM*&Za@a*_5zcyZkY5
z(wGOTtW{%>xTB0oO>z1$5YF{-D0Zurs)+~VBJGpQ!7Q~Fd#J*YxI36ZEy}33gFuBB
zc!XVeiTH9yM`I|2o|*aU3r1ZV51fwkq!mJlDfClS2Ai(v5#4Z=y)nlg78@Ak-i@q?
zE^O0kk)Y$j<-0|#An11k(tGCy7PUhG)%RKH4n{=ov?SWz{vdH24$YfBSqYQg@E~XT
z(lDs&=76`>p>P3IM#e(ypSr@&+#>gX;ESYdqgcL5_?#OX{>G2hLBpu^QG#egq-YU-%&18wrfmJJU}TP~PrG
zm6-!tO?^Ws+omIOQU7%&^^*cb-dRzEw85-#xXPjmOh`2E@meHapAMqgKdnX50h~LP
zOLZAGU8^j<>)Z0QY;MAOg7HelWtIdr
zD`kr@wy&Y_Wyjn0XB`z~Z=V(Crm<~Cwq7i(Z9KkLepj#^lpV8859(i36e~>klvaLw
zhXI>H0mn}qFSuzmB<*SvFT
z7wKGkh*@8GA)ObI7^wo$;M`qftvAyXeMbp~vS7Eo>s2Mii5x83=DP?oRXoQ9)*ER*
z-w6UY77QBbc%za2K8Ycs^TQ?axsjo^rGUPzpt6uHOww}_Jop5~e%m0_UEwjEJn_PD
zG`i}$*aBCS2&bKki^)7v??yo85j(;oHXwk5kPR^%LXX|P?#T9+ekX9t_?n=YB|U2E
zb@V9TSpu`i&0CD+BF-9n+4hS#&OC)tf1CV0GN}vb3=)Dm6xlg^=qqaViZ$X{7Le*7
zXm^q_;dF}W`R-}F-chAY*>@mfJTBPfz#y~^rjl&w8hc2#fZnbHAX*tc_4rV5mHDy-
zmMpMg6T|e|K;)>zXzS=I9WJViiK<1NtXhEXf9^+zz8>syFYx-t@
zbfL~|0U>ILHaZwwp?xw0bO+;ej649**4x*sJ3j9Sh6MLwAzt-ll$TqyP_a;BaKG#`+UYn>;vC#X9$+|R!v+;8BW@NkrrDi8ZcJ*j=F=NDEO!QSOL~kBxHyBlKydF|E9<
z2xLM+l$2Onh-^Cs|4$t_jqM-LpRV?@um_)@lSMt87L>7rpek>jnrTgu8LZMEM0qQy
z^XXjB_j3YRn1M6iF$X7gux#tag^*d;ySNbX3dfYPW#5Bnxw?b1rvD428HD1!CTiYZ
zwC^hn4HK@!>5#d{jLUWOtoLB}&BUldcwfIr=y1XoE+0fTh5IlpM3~|K1zQM%&x|oM
z2;YOT*GjA_pt&%N$(+RHA{uSM#q{1iif2NVJ{U5K%=|E`oYb_G
z1~6d+%r$a>kGrj4kj?|$F-!pZ{gzi|@*frda@V(*3*D35X-^c}=B60X+k$3b6&24R
zt;^N)~>z%N^{3Gap*QE$QbSeAa
zbtyqG$wmMj9nv9?n96zmrPL62&R$*%6fhdD`{4e^1pyf)BvuSgm3S(yb@4Hd&jZpNe!t
z5rpO*tI|t?VEeZcfQKuRbN%&d)UND(u97g}lh4z%eb+NU<;@3Y_3Ji_#c3kAEN#%b
z67*IQVta2E&tQ^G13v*ht*avEYK0ZJGYPt3ZpG1p3L7ff>tcH_K`rvWv$<1&pE8~R
z&V|jzd)`Y|nv2gs0*T37s}FtK>B`WR*Wi1r_HV`CO<-eUg=$Q&8?M%&)J(;(Lj-*SZz}O#^fAQ0HUc2-sQ!9b)$K42bqsIt%^uT9
z?4(!5pu@LKe%^SEAelhU%Thc>{GG;Li1{Mo#jwatuzj})cg>$I>e1J4$E*X{`tjEI
z5r^M$k_Y$^$H#BEan@i9)NTAb!o}g~EfOBJUfMq+?a?sUZ_O6%xLsWTFy+c^r0Sg&^N`cCy~1l_3`$=sCva|5Bs0Yn33hQG7vel&ff
zC8*YJFfH_XyJ9|4si#VxOD4mPC1=|DSeQTQTVo-=$^5T@h6}%D+shO+Z*!qHZ695i
zkEodNt^-M%%f>=?y`MN?Kak@FdO7~*njK+RN}%pEkk`{*%PYsi$KzG|_{ul+z3k_n
zD4{8wxdN2_S?XXtq3jSeIUdMyhzp{&Ewh)!X}=!}IafZDM%bU;z<_5(cXJ(NXE*f|
z%CKo-k1NOP!a!Aj#*pZ{F{hnH9$Y9MGyhvVHkm)za86)3ubU7JbTMx<-l)kFkB-Y6
va$yK{Qj#AnEEu)=$&zBhlcKBfUz&)cXX3UC3Z7y_1I(T|KPZ2?bn8C>qZMKZ
literal 0
HcmV?d00001
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
new file mode 100644
index 0000000..1fdf402
--- /dev/null
+++ b/src-tauri/src/main.rs
@@ -0,0 +1,475 @@
+// Summarum — Tauri shell: tray, hide-to-tray window, app-data storage,
+// currency rates fetching with an on-disk cache, extension script loading.
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+use tauri::menu::{Menu, MenuItem};
+use tauri::tray::TrayIconBuilder;
+use tauri::{AppHandle, Emitter, Manager, WindowEvent};
+
+const RATES_TTL_SECS: u64 = 3600;
+const SAMPLE_JS: &str = include_str!("../extension-sample.js");
+
+fn data_dir(app: &AppHandle) -> PathBuf {
+ let dir = app
+ .path()
+ .app_data_dir()
+ .expect("no app data dir");
+ fs::create_dir_all(&dir).ok();
+ dir
+}
+
+/// Documents live either in the default app-data dir or in a user-chosen
+/// folder (settings stay in app-data — the path itself is stored there).
+fn docs_dir(app: &AppHandle, dir: &Option) -> PathBuf {
+ match dir {
+ Some(d) if !d.trim().is_empty() => {
+ let p = PathBuf::from(d);
+ fs::create_dir_all(&p).ok();
+ p
+ }
+ _ => data_dir(app),
+ }
+}
+
+fn safe_name(name: &str) -> bool {
+ !name.is_empty() && !name.contains(['/', '\\', ':']) && !name.contains("..")
+}
+
+#[tauri::command]
+fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), String> {
+ if !safe_name(&name) {
+ return Err("bad file name".into());
+ }
+ fs::write(data_dir(&app).join(name), contents).map_err(|e| e.to_string())
+}
+
+#[tauri::command]
+fn load_file(app: AppHandle, name: String) -> Result, String> {
+ if !safe_name(&name) {
+ return Err("bad file name".into());
+ }
+ let path = data_dir(&app).join(name);
+ if !path.exists() {
+ return Ok(None);
+ }
+ fs::read_to_string(path).map(Some).map_err(|e| e.to_string())
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+struct RatesPayload {
+ date: String,
+ rates: serde_json::Map,
+ #[serde(rename = "fetchedAt", default)]
+ fetched_at: u64,
+}
+
+/// top cryptocurrencies fetched from CoinGecko (code, gecko id)
+const CRYPTO: &[(&str, &str)] = &[
+ ("BTC", "bitcoin"), ("ETH", "ethereum"), ("SOL", "solana"), ("BNB", "binancecoin"),
+ ("XRP", "ripple"), ("ADA", "cardano"), ("DOGE", "dogecoin"), ("TRX", "tron"),
+ ("TON", "the-open-network"), ("DOT", "polkadot"), ("LTC", "litecoin"),
+ ("AVAX", "avalanche-2"), ("LINK", "chainlink"), ("UNI", "uniswap"), ("XLM", "stellar"),
+ ("XMR", "monero"), ("ATOM", "cosmos"), ("BCH", "bitcoin-cash"), ("NEAR", "near"),
+ ("USDT", "tether"), ("USDC", "usd-coin"),
+];
+
+#[derive(Serialize, Deserialize)]
+struct RatesCache {
+ fetched_at: u64,
+ payload: RatesPayload,
+}
+
+fn now_secs() -> u64 {
+ SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
+}
+
+fn read_rates_cache(app: &AppHandle) -> Option {
+ let raw = fs::read_to_string(data_dir(app).join("rates.json")).ok()?;
+ serde_json::from_str(&raw).ok()
+}
+
+async fn fetch_rates_online() -> Result {
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(15))
+ .build()
+ .map_err(|e| e.to_string())?;
+
+ let body: serde_json::Value = client
+ .get("https://open.er-api.com/v6/latest/USD")
+ .send()
+ .await
+ .map_err(|e| e.to_string())?
+ .json()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ if body["result"] != "success" {
+ return Err("rates api error".into());
+ }
+ let mut rates = body["rates"].as_object().cloned().ok_or("no rates")?;
+
+ // crypto via CoinGecko (er-api has no crypto); best-effort
+ let ids: Vec<&str> = CRYPTO.iter().map(|(_, id)| *id).collect();
+ let url = format!(
+ "https://api.coingecko.com/api/v3/simple/price?ids={}&vs_currencies=usd",
+ ids.join(",")
+ );
+ if let Ok(resp) = client.get(&url).send().await {
+ if let Ok(json) = resp.json::().await {
+ for (code, id) in CRYPTO {
+ if let Some(price) = json[id]["usd"].as_f64() {
+ if price > 0.0 {
+ rates.insert((*code).into(), serde_json::json!(1.0 / price));
+ }
+ }
+ }
+ }
+ }
+
+ Ok(RatesPayload {
+ date: body["time_last_update_utc"].as_str().unwrap_or("").to_string(),
+ rates,
+ fetched_at: now_secs(),
+ })
+}
+
+#[tauri::command]
+async fn fetch_rates(app: AppHandle, force: bool) -> Result {
+ if !force {
+ if let Some(cache) = read_rates_cache(&app) {
+ if now_secs().saturating_sub(cache.fetched_at) < RATES_TTL_SECS {
+ let mut payload = cache.payload;
+ payload.fetched_at = cache.fetched_at;
+ return Ok(payload);
+ }
+ }
+ }
+ match fetch_rates_online().await {
+ Ok(payload) => {
+ let cache = RatesCache { fetched_at: now_secs(), payload: payload.clone() };
+ if let Ok(raw) = serde_json::to_string(&cache) {
+ fs::write(data_dir(&app).join("rates.json"), raw).ok();
+ }
+ Ok(payload)
+ }
+ Err(e) => read_rates_cache(&app)
+ .map(|c| {
+ let mut payload = c.payload;
+ payload.fetched_at = c.fetched_at;
+ payload
+ })
+ .ok_or(e),
+ }
+}
+
+// ---------- documents in the (possibly custom) data folder
+
+#[tauri::command]
+fn load_documents(app: AppHandle, dir: Option) -> Result, String> {
+ let path = docs_dir(&app, &dir).join("documents.json");
+ if !path.exists() {
+ return Ok(None);
+ }
+ fs::read_to_string(path).map(Some).map_err(|e| e.to_string())
+}
+
+#[tauri::command]
+fn save_documents(app: AppHandle, dir: Option, contents: String) -> Result<(), String> {
+ fs::write(docs_dir(&app, &dir).join("documents.json"), contents).map_err(|e| e.to_string())
+}
+
+// ---------- backups
+
+const SNAPSHOT_KEEP: usize = 14;
+
+/// Daily snapshot of documents.json + retention pruning. Runs at startup
+/// before the app writes anything, so it captures yesterday's state.
+#[tauri::command]
+fn run_backups(app: AppHandle, dir: Option, retention_days: u32) -> Result<(), String> {
+ let base = docs_dir(&app, &dir);
+ let docs = base.join("documents.json");
+ let bdir = base.join("backups");
+ fs::create_dir_all(bdir.join("deleted")).map_err(|e| e.to_string())?;
+
+ if docs.exists() {
+ let name = format!("documents-{}.json", chrono::Local::now().format("%Y-%m-%d"));
+ let snapshot = bdir.join(&name);
+ if !snapshot.exists() {
+ fs::copy(&docs, &snapshot).map_err(|e| e.to_string())?;
+ }
+ }
+
+ // prune snapshots: keep the newest SNAPSHOT_KEEP by name (dates sort lexically)
+ let mut snaps: Vec = fs::read_dir(&bdir)
+ .map_err(|e| e.to_string())?
+ .flatten()
+ .map(|e| e.path())
+ .filter(|p| {
+ p.is_file()
+ && p.file_name()
+ .and_then(|n| n.to_str())
+ .is_some_and(|n| n.starts_with("documents-") && n.ends_with(".json"))
+ })
+ .collect();
+ snaps.sort();
+ if snaps.len() > SNAPSHOT_KEEP {
+ for old in &snaps[..snaps.len() - SNAPSHOT_KEEP] {
+ fs::remove_file(old).ok();
+ }
+ }
+
+ // prune the deleted-sheets bin by age
+ let cutoff = SystemTime::now() - std::time::Duration::from_secs(u64::from(retention_days) * 86400);
+ if let Ok(entries) = fs::read_dir(bdir.join("deleted")) {
+ for entry in entries.flatten() {
+ if let Ok(meta) = entry.metadata() {
+ if meta.modified().map(|m| m < cutoff).unwrap_or(false) {
+ fs::remove_file(entry.path()).ok();
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// A deleted sheet goes to the bin as a .numi file (restore = drag it back).
+#[tauri::command]
+fn backup_deleted_sheet(app: AppHandle, dir: Option, title: String, contents: String) -> Result<(), String> {
+ let bin = docs_dir(&app, &dir).join("backups").join("deleted");
+ fs::create_dir_all(&bin).map_err(|e| e.to_string())?;
+ let safe: String = title
+ .chars()
+ .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_')
+ .take(40)
+ .collect();
+ let safe = if safe.trim().is_empty() { "sheet".to_string() } else { safe.trim().to_string() };
+ let name = format!("{}-{}.numi", safe, chrono::Local::now().format("%Y-%m-%d-%H%M%S"));
+ fs::write(bin.join(name), contents).map_err(|e| e.to_string())
+}
+
+#[tauri::command]
+fn open_backups_folder(app: AppHandle, dir: Option) -> Result<(), String> {
+ let bdir = docs_dir(&app, &dir).join("backups");
+ fs::create_dir_all(&bdir).ok();
+ std::process::Command::new("explorer")
+ .arg(bdir)
+ .spawn()
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+// ---------- data folder migration
+
+#[tauri::command]
+fn data_dir_has_documents(dir: String) -> bool {
+ PathBuf::from(dir).join("documents.json").exists()
+}
+
+/// strategy: "move" (copy mine, delete originals), "overwrite" (same, target
+/// had documents), "use_existing" (just switch — keep the target's files)
+#[tauri::command]
+fn migrate_data_dir(app: AppHandle, old_dir: Option, new_dir: String, strategy: String) -> Result<(), String> {
+ let old = docs_dir(&app, &old_dir);
+ let new = PathBuf::from(&new_dir);
+ fs::create_dir_all(&new).map_err(|e| e.to_string())?;
+ if old == new {
+ return Ok(());
+ }
+ if strategy == "use_existing" {
+ return Ok(());
+ }
+ let old_docs = old.join("documents.json");
+ if old_docs.exists() {
+ fs::copy(&old_docs, new.join("documents.json")).map_err(|e| e.to_string())?;
+ }
+ // merge backups (skip files that already exist in the target)
+ for sub in ["backups", "backups/deleted"] {
+ let from = old.join(sub);
+ let to = new.join(sub);
+ fs::create_dir_all(&to).ok();
+ if let Ok(entries) = fs::read_dir(&from) {
+ for entry in entries.flatten() {
+ let dest = to.join(entry.file_name());
+ if entry.path().is_file() && !dest.exists() {
+ fs::copy(entry.path(), dest).ok();
+ }
+ }
+ }
+ }
+ // originals removed only after everything is copied
+ if old_docs.exists() {
+ fs::remove_file(&old_docs).ok();
+ }
+ fs::remove_dir_all(old.join("backups")).ok();
+ Ok(())
+}
+
+/// Used by drag&drop: read a calculation/text file from an absolute path.
+#[tauri::command]
+fn read_text_file(path: String) -> Result {
+ let p = std::path::PathBuf::from(&path);
+ let ext = p
+ .extension()
+ .and_then(|e| e.to_str())
+ .unwrap_or("")
+ .to_lowercase();
+ if !["numi", "txt", "md"].contains(&ext.as_str()) {
+ return Err("unsupported file type".into());
+ }
+ let meta = fs::metadata(&p).map_err(|e| e.to_string())?;
+ if meta.len() > 1_000_000 {
+ return Err("file too large".into());
+ }
+ fs::read_to_string(p).map_err(|e| e.to_string())
+}
+
+#[derive(Serialize)]
+struct ExtensionScript {
+ name: String,
+ code: String,
+}
+
+fn extensions_dir(app: &AppHandle) -> PathBuf {
+ let dir = data_dir(app).join("extensions");
+ if fs::create_dir_all(&dir).is_ok() {
+ // ship Sample.js from the macOS bundle as the starter example
+ let sample = dir.join("Sample.js");
+ if !sample.exists() {
+ fs::write(sample, SAMPLE_JS).ok();
+ }
+ }
+ dir
+}
+
+#[tauri::command]
+fn load_extensions(app: AppHandle) -> Vec {
+ let mut out = Vec::new();
+ if let Ok(entries) = fs::read_dir(extensions_dir(&app)) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|e| e == "js") {
+ if let Ok(code) = fs::read_to_string(&path) {
+ out.push(ExtensionScript {
+ name: entry.file_name().to_string_lossy().into_owned(),
+ code,
+ });
+ }
+ }
+ }
+ }
+ out.sort_by(|a, b| a.name.cmp(&b.name));
+ out
+}
+
+/// Contents of a .numi file passed on the command line (file association).
+#[tauri::command]
+fn get_launch_file() -> Option {
+ let arg = std::env::args().nth(1)?;
+ if arg.to_lowercase().ends_with(".numi") {
+ fs::read_to_string(arg).ok()
+ } else {
+ None
+ }
+}
+
+#[tauri::command]
+fn open_extensions_folder(app: AppHandle) -> Result<(), String> {
+ let dir = extensions_dir(&app);
+ std::process::Command::new("explorer")
+ .arg(dir)
+ .spawn()
+ .map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+fn toggle_window(app: &AppHandle) {
+ if let Some(win) = app.get_webview_window("main") {
+ let visible = win.is_visible().unwrap_or(false);
+ if visible && win.is_focused().unwrap_or(false) {
+ win.hide().ok();
+ } else {
+ win.show().ok();
+ win.set_focus().ok();
+ }
+ }
+}
+
+fn main() {
+ tauri::Builder::default()
+ .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
+ if let Some(path) = args.iter().skip(1).find(|a| a.to_lowercase().ends_with(".numi")) {
+ if let Ok(content) = fs::read_to_string(path) {
+ app.emit("open-file", content).ok();
+ }
+ }
+ if let Some(win) = app.get_webview_window("main") {
+ win.show().ok();
+ win.set_focus().ok();
+ }
+ }))
+ .plugin(tauri_plugin_global_shortcut::Builder::new().build())
+ .plugin(tauri_plugin_autostart::init(
+ tauri_plugin_autostart::MacosLauncher::LaunchAgent,
+ None,
+ ))
+ .plugin(tauri_plugin_opener::init())
+ .plugin(tauri_plugin_dialog::init())
+ .invoke_handler(tauri::generate_handler![
+ save_file,
+ load_file,
+ load_documents,
+ save_documents,
+ run_backups,
+ backup_deleted_sheet,
+ open_backups_folder,
+ data_dir_has_documents,
+ migrate_data_dir,
+ fetch_rates,
+ load_extensions,
+ open_extensions_folder,
+ get_launch_file,
+ read_text_file
+ ])
+ .setup(|app| {
+ let handle = app.handle().clone();
+ let show = MenuItem::with_id(app, "show", "Show / Hide", true, None::<&str>)?;
+ let quit = MenuItem::with_id(app, "quit", "Quit Summarum", true, None::<&str>)?;
+ let menu = Menu::with_items(app, &[&show, &quit])?;
+ TrayIconBuilder::with_id("tray")
+ .icon(app.default_window_icon().unwrap().clone())
+ .tooltip("Summarum")
+ .menu(&menu)
+ .show_menu_on_left_click(false)
+ .on_menu_event(move |app, event| match event.id.as_ref() {
+ "show" => toggle_window(app),
+ "quit" => app.exit(0),
+ _ => {}
+ })
+ .on_tray_icon_event(|tray, event| {
+ if let tauri::tray::TrayIconEvent::Click {
+ button: tauri::tray::MouseButton::Left,
+ button_state: tauri::tray::MouseButtonState::Up,
+ ..
+ } = event
+ {
+ toggle_window(tray.app_handle());
+ }
+ })
+ .build(app)?;
+ let _ = handle;
+ Ok(())
+ })
+ .on_window_event(|window, event| {
+ // close hides to tray
+ if let WindowEvent::CloseRequested { api, .. } = event {
+ window.hide().ok();
+ api.prevent_close();
+ }
+ })
+ .run(tauri::generate_context!())
+ .expect("error while running Summarum");
+}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
new file mode 100644
index 0000000..ce89525
--- /dev/null
+++ b/src-tauri/tauri.conf.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "productName": "Summarum",
+ "version": "1.0.0",
+ "identifier": "app.summarum.calc",
+ "build": {
+ "beforeDevCommand": "npm run dev",
+ "devUrl": "http://localhost:1420",
+ "beforeBuildCommand": "npm run build",
+ "frontendDist": "../dist"
+ },
+ "app": {
+ "windows": [
+ {
+ "title": "Summarum",
+ "width": 480,
+ "height": 620,
+ "minWidth": 320,
+ "minHeight": 240,
+ "center": true,
+ "visible": true,
+ "decorations": false
+ }
+ ],
+ "security": {
+ "csp": null
+ }
+ },
+ "bundle": {
+ "active": true,
+ "targets": ["nsis"],
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.png",
+ "icons/icon.ico"
+ ],
+ "fileAssociations": [
+ {
+ "ext": ["numi", "sum"],
+ "name": "Summarum Sheet",
+ "description": "Summarum calculation sheet",
+ "role": "Editor"
+ }
+ ],
+ "windows": {
+ "nsis": {
+ "installMode": "currentUser",
+ "languages": ["English", "Russian"]
+ }
+ }
+ }
+}
diff --git a/src/engine/datetime.ts b/src/engine/datetime.ts
new file mode 100644
index 0000000..ef3c0d6
--- /dev/null
+++ b/src/engine/datetime.ts
@@ -0,0 +1,109 @@
+// Date helpers and a city → IANA timezone map for "time in Tokyo".
+
+const Z: Record = {
+ // ---- common abbreviations
+ utc: "UTC", gmt: "UTC", est: "America/New_York", edt: "America/New_York",
+ cst: "America/Chicago", cdt: "America/Chicago", mst: "America/Denver",
+ pst: "America/Los_Angeles", pdt: "America/Los_Angeles", msk: "Europe/Moscow",
+ cet: "Europe/Paris", eet: "Europe/Kyiv", jst: "Asia/Tokyo", ist: "Asia/Kolkata",
+ // ---- cities (en)
+ "new york": "America/New_York", "los angeles": "America/Los_Angeles",
+ "san francisco": "America/Los_Angeles", chicago: "America/Chicago",
+ seattle: "America/Los_Angeles", boston: "America/New_York", miami: "America/New_York",
+ toronto: "America/Toronto", vancouver: "America/Vancouver",
+ "mexico city": "America/Mexico_City", "sao paulo": "America/Sao_Paulo",
+ "buenos aires": "America/Argentina/Buenos_Aires", lima: "America/Lima",
+ london: "Europe/London", dublin: "Europe/Dublin", lisbon: "Europe/Lisbon",
+ madrid: "Europe/Madrid", barcelona: "Europe/Madrid", paris: "Europe/Paris",
+ berlin: "Europe/Berlin", munich: "Europe/Berlin", amsterdam: "Europe/Amsterdam",
+ brussels: "Europe/Brussels", zurich: "Europe/Zurich", geneva: "Europe/Zurich",
+ rome: "Europe/Rome", milan: "Europe/Rome", vienna: "Europe/Vienna",
+ prague: "Europe/Prague", warsaw: "Europe/Warsaw", stockholm: "Europe/Stockholm",
+ oslo: "Europe/Oslo", copenhagen: "Europe/Copenhagen", helsinki: "Europe/Helsinki",
+ athens: "Europe/Athens", istanbul: "Europe/Istanbul", kyiv: "Europe/Kyiv",
+ kiev: "Europe/Kyiv", minsk: "Europe/Minsk", moscow: "Europe/Moscow",
+ "saint petersburg": "Europe/Moscow", riga: "Europe/Riga", vilnius: "Europe/Vilnius",
+ tallinn: "Europe/Tallinn", tbilisi: "Asia/Tbilisi", yerevan: "Asia/Yerevan",
+ baku: "Asia/Baku", dubai: "Asia/Dubai", "abu dhabi": "Asia/Dubai",
+ "tel aviv": "Asia/Jerusalem", jerusalem: "Asia/Jerusalem", riyadh: "Asia/Riyadh",
+ doha: "Asia/Qatar", tehran: "Asia/Tehran", karachi: "Asia/Karachi",
+ delhi: "Asia/Kolkata", mumbai: "Asia/Kolkata", bangalore: "Asia/Kolkata",
+ kolkata: "Asia/Kolkata", dhaka: "Asia/Dhaka", bangkok: "Asia/Bangkok",
+ hanoi: "Asia/Ho_Chi_Minh", "ho chi minh": "Asia/Ho_Chi_Minh",
+ jakarta: "Asia/Jakarta", singapore: "Asia/Singapore", "kuala lumpur": "Asia/Kuala_Lumpur",
+ manila: "Asia/Manila", "hong kong": "Asia/Hong_Kong", taipei: "Asia/Taipei",
+ beijing: "Asia/Shanghai", shanghai: "Asia/Shanghai", shenzhen: "Asia/Shanghai",
+ seoul: "Asia/Seoul", tokyo: "Asia/Tokyo", osaka: "Asia/Tokyo",
+ sydney: "Australia/Sydney", melbourne: "Australia/Melbourne",
+ brisbane: "Australia/Brisbane", perth: "Australia/Perth", auckland: "Pacific/Auckland",
+ cairo: "Africa/Cairo", lagos: "Africa/Lagos", nairobi: "Africa/Nairobi",
+ johannesburg: "Africa/Johannesburg", "cape town": "Africa/Johannesburg",
+ casablanca: "Africa/Casablanca", almaty: "Asia/Almaty", astana: "Asia/Almaty",
+ tashkent: "Asia/Tashkent", bishkek: "Asia/Bishkek", novosibirsk: "Asia/Novosibirsk",
+ yekaterinburg: "Asia/Yekaterinburg", vladivostok: "Asia/Vladivostok",
+ // ---- cities (ru)
+ "нью йорк": "America/New_York", "лос анджелес": "America/Los_Angeles",
+ "сан франциско": "America/Los_Angeles", чикаго: "America/Chicago",
+ торонто: "America/Toronto", лондон: "Europe/London", париж: "Europe/Paris",
+ берлин: "Europe/Berlin", мадрид: "Europe/Madrid", рим: "Europe/Rome",
+ амстердам: "Europe/Amsterdam", вена: "Europe/Vienna", прага: "Europe/Prague",
+ варшава: "Europe/Warsaw", стокгольм: "Europe/Stockholm", хельсинки: "Europe/Helsinki",
+ афины: "Europe/Athens", стамбул: "Europe/Istanbul", киев: "Europe/Kyiv",
+ минск: "Europe/Minsk", москва: "Europe/Moscow", москве: "Europe/Moscow",
+ питер: "Europe/Moscow", "санкт петербург": "Europe/Moscow", петербург: "Europe/Moscow",
+ рига: "Europe/Riga", вильнюс: "Europe/Vilnius", таллин: "Europe/Tallinn",
+ тбилиси: "Asia/Tbilisi", ереван: "Asia/Yerevan", баку: "Asia/Baku",
+ дубай: "Asia/Dubai", дубае: "Asia/Dubai", "тель авив": "Asia/Jerusalem",
+ дели: "Asia/Kolkata", мумбаи: "Asia/Kolkata", бангкок: "Asia/Bangkok",
+ бангкоке: "Asia/Bangkok", сингапур: "Asia/Singapore", сингапуре: "Asia/Singapore",
+ джакарта: "Asia/Jakarta", гонконг: "Asia/Hong_Kong", пекин: "Asia/Shanghai",
+ шанхай: "Asia/Shanghai", сеул: "Asia/Seoul", токио: "Asia/Tokyo",
+ сидней: "Australia/Sydney", мельбурн: "Australia/Melbourne", окленд: "Pacific/Auckland",
+ каир: "Africa/Cairo", алматы: "Asia/Almaty", астана: "Asia/Almaty",
+ ташкент: "Asia/Tashkent", бишкек: "Asia/Bishkek", новосибирск: "Asia/Novosibirsk",
+ екатеринбург: "Asia/Yekaterinburg", владивосток: "Asia/Vladivostok",
+};
+
+/** Resolve a sequence of words to an IANA timezone. Tries longest run first. */
+export function resolveZone(words: string[]): string | null {
+ const lower = words.map((w) => w.toLowerCase().replace(/[-.]/g, " ").trim());
+ if (lower[0] === "local") return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ for (let len = Math.min(lower.length, 3); len >= 1; len--) {
+ const key = lower.slice(0, len).join(" ");
+ if (Z[key]) return Z[key];
+ }
+ // direct IANA id like Europe/Berlin typed by hand
+ const joined = words.join("/");
+ try {
+ new Intl.DateTimeFormat("en", { timeZone: joined });
+ return joined;
+ } catch {
+ return null;
+ }
+}
+
+export function startOfToday(): number {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ return d.getTime();
+}
+
+/** Calendar-aware add: months/years via Date methods, the rest via ms. */
+export function addToDate(ms: number, amount: number, unitId: string): number {
+ const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
+ const d = new Date(ms);
+ if (baseId === "month" && Number.isInteger(amount)) {
+ d.setMonth(d.getMonth() + amount);
+ return d.getTime();
+ }
+ if (baseId === "year" && Number.isInteger(amount)) {
+ d.setFullYear(d.getFullYear() + amount);
+ return d.getTime();
+ }
+ return ms; // caller falls back to ms math for everything else
+}
+
+export function isCalendarUnit(unitId: string, amount: number): boolean {
+ const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
+ return (baseId === "month" || baseId === "year") && Number.isInteger(amount);
+}
diff --git a/src/engine/edge.test.ts b/src/engine/edge.test.ts
new file mode 100644
index 0000000..2aa34e0
--- /dev/null
+++ b/src/engine/edge.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect, beforeAll } from "vitest";
+import { SumEngine } from "./index";
+
+let eng: SumEngine;
+
+beforeAll(() => {
+ eng = new SumEngine();
+ eng.setRates({ USD: 1, EUR: 0.5, RUB: 100, GBP: 0.25 });
+});
+
+function calc(expr: string): string | null {
+ const r = eng.evaluateDocument(expr);
+ return r[r.length - 1].text;
+}
+
+describe("edge cases", () => {
+ it("russian mixed sentence", () => {
+ expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
+ });
+ it("russian conversion to currency", () => {
+ expect(calc("20$ в рублях")).toBe("2,000 ₽");
+ });
+ it("generic 'доллар' means USD, not TTD", () => {
+ expect(calc("1 доллар")).toBe("$1");
+ expect(calc("1 доллар в рублях")).toBe("100 ₽");
+ });
+ it("chained percent discounts", () => {
+ expect(calc("100 - 10% - 10%")).toBe("81");
+ });
+ it("conversion chain", () => {
+ expect(calc("1 km in m in cm")).toBe("100,000 cm");
+ });
+ it("euro symbol", () => {
+ expect(calc("€10 + €5")).toBe("€ 15");
+ });
+ it("pow precedence", () => {
+ expect(calc("2 * 3^2")).toBe("18");
+ expect(calc("-2^2")).toBe("-4");
+ });
+ it("last expression wins for unrelated numbers", () => {
+ expect(calc("page 10 results 2 + 3")).toBe("5");
+ });
+ it("empty and label lines give no result", () => {
+ expect(calc("")).toBe(null);
+ expect(calc("hello world")).toBe(null);
+ });
+ it("sum stops at header", () => {
+ const r = eng.evaluateDocument("5\n# section\n10\n20\nsum").map((x) => x.text);
+ expect(r).toEqual(["5", null, "10", "20", "30"]);
+ });
+ it("variables with units survive math", () => {
+ const r = eng.evaluateDocument("rent = $500\nfood = $200\nrent + food").map((x) => x.text);
+ expect(r).toEqual(["$500", "$200", "$700"]);
+ });
+ it("big numbers with grouping", () => {
+ expect(calc("1000000 * 2")).toBe("2,000,000");
+ });
+ it("division by zero yields no result", () => {
+ expect(calc("5 / 0")).toBe(null);
+ });
+ it("trailing garbage does not break expression", () => {
+ expect(calc("2 + 2 =")).toBe("4");
+ });
+ it("scientific output", () => {
+ expect(calc("123456 in scientific")).toBe("1.23e5"); // mantissa follows precision setting
+ });
+ it("GiB in GB", () => {
+ expect(calc("1 GiB in GB")).toBe("1.07 GB");
+ });
+ it("Kelvin word", () => {
+ expect(calc("300 kelvin in celsius")).toBe("26.85°C");
+ });
+ it("milliseconds", () => {
+ expect(calc("1500 ms in seconds")).toBe("1.5 s");
+ });
+ it("square meters word form", () => {
+ expect(calc("3 square meters in sq ft")).toBe("32.29 ft²");
+ });
+ it("multiplication of money by count", () => {
+ expect(calc("$12 * 3")).toBe("$36");
+ });
+});
+
+describe("extra units (beyond original Numi)", () => {
+ it("speed", () => {
+ expect(calc("100 km/h in mph")).toBe("62.14 mph");
+ expect(calc("60 mph in km/h")).toBe("96.56 km/h");
+ expect(calc("10 m/s in km/h")).toBe("36 km/h");
+ });
+ it("pressure", () => {
+ expect(calc("1 atm in mmHg")).toBe("760 mmHg");
+ expect(calc("2 bar in psi")).toBe("29.01 psi");
+ });
+ it("energy", () => {
+ expect(calc("1 kwh in kJ")).toBe("3,600 kJ");
+ expect(calc("500 kcal in kJ")).toBe("2,092 kJ");
+ });
+ it("power", () => {
+ expect(calc("150 hp in kW")).toBe("111.85 kW");
+ expect(calc("100 лс в kW")).toBe("73.55 kW");
+ });
+ it("frequency", () => {
+ expect(calc("3000 rpm in Hz")).toBe("50 Hz");
+ });
+ it("fuel (reciprocal mpg)", () => {
+ expect(calc("8 l/100km in mpg")).toBe("29.4 mpg");
+ expect(calc("30 mpg in l/100km")).toBe("7.84 L/100km");
+ });
+});
+
+describe("bitwise and unix", () => {
+ it("bit ops", () => {
+ expect(calc("0xF0 | 0x0F")).toBe("0xFF");
+ expect(calc("0xFF & 0x0F")).toBe("0xF");
+ expect(calc("5 xor 3")).toBe("6");
+ expect(calc("1 << 4")).toBe("16");
+ expect(calc("1 shl 4")).toBe("16");
+ expect(calc("256 >> 4")).toBe("16");
+ });
+ it("unix timestamps", () => {
+ expect(calc("today in unix")).toMatch(/^\d{10}$/);
+ const r = eng.evaluateDocument("1750000000 as date");
+ expect(r[0].value?.kind).toBe("date");
+ });
+});
+
+describe("fraction and roman", () => {
+ it("fractions", () => {
+ expect(calc("0.75 in fraction")).toBe("3/4");
+ expect(calc("1.5 in fraction")).toBe("1 1/2");
+ expect(calc("1/3 + 1/3 in fraction")).toBe("2/3");
+ });
+ it("roman", () => {
+ expect(calc("2026 in roman")).toBe("MMXXVI");
+ expect(calc("49 in roman")).toBe("XLIX");
+ });
+});
+
+describe("crypto", () => {
+ it("eth via snapshot/test rates", () => {
+ const r = calc("1 eth in USD");
+ expect(r).toMatch(/^\$/);
+ });
+});
diff --git a/src/engine/engine.test.ts b/src/engine/engine.test.ts
new file mode 100644
index 0000000..5da94d8
--- /dev/null
+++ b/src/engine/engine.test.ts
@@ -0,0 +1,241 @@
+import { describe, it, expect, beforeAll } from "vitest";
+import { SumEngine } from "./index";
+
+let eng: SumEngine;
+
+beforeAll(() => {
+ eng = new SumEngine();
+ // deterministic rates for tests
+ eng.setRates({ USD: 1, EUR: 0.5, RUB: 100, GBP: 0.25, BTC: 0.00001 });
+});
+
+function calc(expr: string): string | null {
+ const r = eng.evaluateDocument(expr);
+ return r[r.length - 1].text;
+}
+
+function calcDoc(doc: string): (string | null)[] {
+ return eng.evaluateDocument(doc).map((r) => r.text);
+}
+
+describe("arithmetic", () => {
+ it("basic ops", () => {
+ expect(calc("2 + 2")).toBe("4");
+ expect(calc("7 - 10")).toBe("-3");
+ expect(calc("6 * 7")).toBe("42");
+ expect(calc("1 / 8")).toBe("0.13");
+ expect(calc("2 ^ 10")).toBe("1,024");
+ expect(calc("10 mod 3")).toBe("1");
+ expect(calc("5!")).toBe("120");
+ });
+ it("decimal precision (no float artifacts)", () => {
+ expect(calc("0.1 + 0.2")).toBe("0.3");
+ });
+ it("parens and precedence", () => {
+ expect(calc("2 + 3 * 4")).toBe("14");
+ expect(calc("(2 + 3) * 4")).toBe("20");
+ expect(calc("2(3+4)")).toBe("14");
+ });
+ it("word operators", () => {
+ expect(calc("5 plus 3")).toBe("8");
+ expect(calc("10 minus 4")).toBe("6");
+ expect(calc("3 times 4")).toBe("12");
+ expect(calc("20 divided by 4")).toBe("5");
+ expect(calc("5 и 3")).toBe("8"); // ru plus
+ });
+ it("thousands separators in input", () => {
+ expect(calc("1,000 + 234")).toBe("1,234");
+ });
+ it("scales", () => {
+ expect(calc("1.5 million")).toBe("1,500,000");
+ expect(calc("5k")).toBe("5,000");
+ expect(calc("2 dozen")).toBe("2"); // dozen is not in Numi vocab — noise
+ });
+ it("free text around math is ignored", () => {
+ expect(calc("I spent 20 on pizza plus 5 on coffee")).toBe("25");
+ });
+ it("constants", () => {
+ expect(calc("pi * 2")).toBe("6.28");
+ });
+ it("functions", () => {
+ expect(calc("sqrt(16)")).toBe("4");
+ expect(calc("sqrt 16")).toBe("4");
+ expect(calc("square root of 16")).toBe("4");
+ expect(calc("cbrt(27)")).toBe("3");
+ expect(calc("abs(-5)")).toBe("5");
+ expect(calc("round(2.6)")).toBe("3");
+ expect(calc("log(1000)")).toBe("3");
+ expect(calc("ln(1)")).toBe("0");
+ });
+});
+
+describe("percent", () => {
+ it("sample: 8 / (45 - 20%)", () => {
+ expect(calc("8 / (45 - 20%)")).toBe("0.22");
+ });
+ it("plus/minus percent", () => {
+ expect(calc("100 + 20%")).toBe("120");
+ expect(calc("100 - 20%")).toBe("80");
+ });
+ it("of / off / on", () => {
+ expect(calc("20% of 100")).toBe("20");
+ expect(calc("20% off 100")).toBe("80");
+ expect(calc("20% on 100")).toBe("120");
+ });
+ it("as a % of", () => {
+ expect(calc("5 as a % of 25")).toBe("20%");
+ });
+ it("of what is", () => {
+ expect(calc("20% of what is 30")).toBe("150");
+ expect(calc("25% off what is 60")).toBe("80");
+ });
+ it("percent arithmetic", () => {
+ expect(calc("5% + 5%")).toBe("10%");
+ expect(calc("100 * 20%")).toBe("20");
+ });
+});
+
+describe("units", () => {
+ it("sample: 5 inches in cm", () => {
+ expect(calc("5 inches in cm")).toBe("12.7 cm");
+ });
+ it("length", () => {
+ expect(calc("1 mile in km")).toBe("1.61 km");
+ expect(calc("100 km in miles")).toBe("62.14 mi.");
+ expect(calc("6 feet in meters")).toBe("1.83 m");
+ });
+ it("prefixed symbols", () => {
+ expect(calc("10 mm in cm")).toBe("1 cm");
+ expect(calc("1 km in m")).toBe("1,000 m");
+ });
+ it("weight", () => {
+ expect(calc("1 kg in pounds")).toBe("2.2 lb");
+ expect(calc("16 oz in pounds")).toBe("1 lb");
+ });
+ it("temperature", () => {
+ expect(calc("100 C in F")).toBe("212°F");
+ expect(calc("32 F in C")).toBe("0°C");
+ expect(calc("0 C in K")).toBe("273.15 K");
+ });
+ it("volume", () => {
+ expect(calc("1 gallon in liters")).toBe("3.79 L");
+ });
+ it("area", () => {
+ expect(calc("1 hectare in acres")).toBe("2.47 acre");
+ expect(calc("2 m * 3 m")).toBe("6 m²");
+ });
+ it("data with SI and IEC prefixes", () => {
+ expect(calc("1 GB in MB")).toBe("1,000 MB");
+ expect(calc("1 GiB in MiB")).toBe("1,024 MiB");
+ expect(calc("1 GB in MiB")).toBe("953.67 MiB");
+ expect(calc("1 byte in bits")).toBe("8 b");
+ });
+ it("css", () => {
+ expect(calc("32 px in em")).toBe("2 em");
+ expect(calc("1 inch in px")).toBe("96 px");
+ });
+ it("time units", () => {
+ expect(calc("90 minutes in hours")).toBe("1.5 h");
+ expect(calc("2 hours 30 minutes in minutes")).toBe("150 min");
+ });
+ it("unit math keeps the left unit", () => {
+ expect(calc("1 m + 50 cm")).toBe("1.5 m");
+ expect(calc("5 ft 4 in in cm")).toBe("162.56 cm");
+ });
+ it("angle trig", () => {
+ expect(calc("sin(90 degrees)")).toBe("1");
+ });
+ it("russian unit phrases", () => {
+ expect(calc("5 метров в см")).toBe("500 cm");
+ });
+});
+
+describe("currency", () => {
+ it("sample: $9 in Euro (test rate 1 USD = 0.5 EUR)", () => {
+ expect(calc("$9 in Euro")).toBe("€ 4.5");
+ });
+ it("postfix and codes", () => {
+ expect(calc("100 USD in RUB")).toBe("10,000 ₽");
+ expect(calc("100 рублей в долларах")).toBe("$1");
+ });
+ it("currency arithmetic", () => {
+ expect(calc("$10 + $5")).toBe("$15");
+ expect(calc("$10 + 20%")).toBe("$12");
+ });
+});
+
+describe("numeral systems", () => {
+ it("input", () => {
+ expect(calc("0x1F")).toBe("0x1F");
+ expect(calc("0b101 in decimal")).toBe("5");
+ });
+ it("conversion", () => {
+ expect(calc("255 in hex")).toBe("0xFF");
+ expect(calc("5 in binary")).toBe("0b101");
+ expect(calc("8 in octal")).toBe("0o10");
+ });
+ it("hex arithmetic keeps repr of the left operand", () => {
+ expect(calc("0x10 + 0x10 in decimal")).toBe("32");
+ });
+});
+
+describe("variables and document context", () => {
+ it("assignment and reuse", () => {
+ expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]);
+ });
+ it("is-assignment", () => {
+ expect(calcDoc("price is 100\nprice + 20%")).toEqual(["100", "120"]);
+ });
+ it("sum / total", () => {
+ expect(calcDoc("10\n20\n30\nsum")).toEqual(["10", "20", "30", "60"]);
+ expect(calcDoc("10\n20\n\n30\nsum")).toEqual(["10", "20", null, "30", "30"]);
+ });
+ it("sum with units", () => {
+ expect(calcDoc("$10\n$20\ntotal")).toEqual(["$10", "$20", "$30"]);
+ });
+ it("avg", () => {
+ expect(calcDoc("10\n20\naverage")).toEqual(["10", "20", "15"]);
+ });
+ it("prev", () => {
+ expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]);
+ });
+ it("headers split blocks and produce no result", () => {
+ expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]);
+ });
+ it("comments", () => {
+ expect(calcDoc("// just a comment\n5 + 5 // inline")).toEqual([null, "10"]);
+ });
+ it("label lines produce no result", () => {
+ expect(calc("Monthly costs")).toBe(null);
+ });
+});
+
+describe("dates", () => {
+ it("today + 2 weeks lands 14 days ahead", () => {
+ const r = eng.evaluateDocument("today + 2 weeks - today");
+ // (today + 2w) - today = 14 days
+ expect(r[0].text).toBe("14 day");
+ });
+ it("tomorrow - today = 1 day", () => {
+ expect(calc("tomorrow - today")).toBe("1 day");
+ });
+ it("time in a timezone returns a formatted time", () => {
+ const t = calc("time in Tokyo");
+ expect(t).toMatch(/^\d{2}:\d{2}$/);
+ });
+});
+
+describe("extension API", () => {
+ it("addUnit (horse from Sample.js)", () => {
+ eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 });
+ expect(calc("2 horses in meters")).toBe("4.8 m");
+ });
+ it("addFunction (zum from Sample.js)", () => {
+ eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double }));
+ expect(calc("zum(2;3)")).toBe("5");
+ });
+ it("setVariable", () => {
+ eng.setVariable("myvar", { double: 5, unitId: "USD" });
+ expect(calc("myvar * 2")).toBe("$10");
+ });
+});
diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts
new file mode 100644
index 0000000..eaca6e6
--- /dev/null
+++ b/src/engine/evaluator.ts
@@ -0,0 +1,466 @@
+// Walks the AST with the document context (variables, line results for
+// sum/avg/prev) and produces unit-aware values.
+import { Decimal, EvalError, Quantity, Unit, Value, qty, pct } from "./types";
+import { Node, ConvTarget } from "./parser";
+import { Registry } from "./registry";
+import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
+
+export interface LineCtx {
+ /** values of lines above (null = no result) */
+ lineValues: (Value | null)[];
+ /** per-line flags for block boundaries */
+ lineKinds: ("empty" | "header" | "normal")[];
+ index: number;
+}
+
+export interface EvalCtx {
+ reg: Registry;
+ vars: Map;
+ line: LineCtx;
+}
+
+const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
+const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
+
+export function evaluate(node: Node, ctx: EvalCtx): Value {
+ switch (node.k) {
+ case "num":
+ return qty(node.v, null, node.repr);
+ case "const":
+ return qty(node.name === "pi" ? PI : E);
+ case "var": {
+ const v = ctx.vars.get(node.name);
+ if (!v) throw new EvalError(`unknown variable ${node.name}`);
+ return v;
+ }
+ case "date":
+ return evalDateWord(node.word);
+ case "agg":
+ return evalAgg(node.name, ctx);
+ case "neg": {
+ const v = evaluate(node.x, ctx);
+ if (v.kind === "quantity") return { ...v, value: v.value.neg() };
+ if (v.kind === "percent") return pct(v.value.neg());
+ throw new EvalError("cannot negate a date");
+ }
+ case "pct": {
+ const v = evaluate(node.x, ctx);
+ if (v.kind === "quantity" && !v.unit) return pct(v.value);
+ if (v.kind === "percent") return v;
+ throw new EvalError("% needs a plain number");
+ }
+ case "fact": {
+ const v = asScalar(evaluate(node.x, ctx));
+ return qty(factorial(v));
+ }
+ case "unit": {
+ const v = evaluate(node.x, ctx);
+ return attachUnit(v, node.unit);
+ }
+ case "curr": {
+ const unit = ctx.reg.makeCurrencyUnit(node.code);
+ if (!unit) throw new EvalError(`no rate for ${node.code}`);
+ const v = evaluate(node.x, ctx);
+ return attachUnit(v, unit);
+ }
+ case "scale": {
+ const v = evaluate(node.x, ctx);
+ if (v.kind === "quantity") return { ...v, value: v.value.mul(node.mult) };
+ if (v.kind === "percent") return pct(v.value.mul(node.mult));
+ throw new EvalError("cannot scale a date");
+ }
+ case "bin":
+ return evalBin(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx), ctx);
+ case "bit":
+ return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
+ case "pctop":
+ return evalPctOp(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
+ case "conv":
+ return evalConv(evaluate(node.x, ctx), node.target, ctx);
+ case "call":
+ return evalCall(node.name, node.args.map((a) => evaluate(a, ctx)), ctx);
+ case "seq": {
+ let acc: Value | null = null;
+ for (const item of node.items) {
+ let v: Value;
+ try {
+ v = evaluate(item, ctx);
+ } catch {
+ continue;
+ }
+ if (
+ acc?.kind === "quantity" && v.kind === "quantity" &&
+ acc.unit && v.unit && acc.unit.dimension === v.unit.dimension &&
+ acc.unit.dimension !== "scalar"
+ ) {
+ // "2 hours 30 min", "5 ft 4 in" — adjacent same-dimension quantities add up
+ acc = addQ(acc, v);
+ } else {
+ acc = v;
+ }
+ }
+ if (!acc) throw new EvalError("empty expression");
+ return acc;
+ }
+ }
+}
+
+// ---------- helpers
+
+function asScalar(v: Value): Decimal {
+ if (v.kind === "quantity") return v.value;
+ if (v.kind === "percent") return v.value.div(100);
+ throw new EvalError("expected a number");
+}
+
+function attachUnit(v: Value, unit: Unit): Value {
+ if (v.kind === "quantity") {
+ if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit);
+ if (v.unit) throw new EvalError("unit mismatch");
+ return { ...v, unit, repr: "decimal" };
+ }
+ throw new EvalError("cannot attach unit");
+}
+
+/** value of q in dimension-base units */
+export function toBase(q: Quantity): Decimal {
+ if (!q.unit) return q.value;
+ if (q.unit.reciprocal) {
+ if (q.value.isZero()) throw new EvalError("division by zero");
+ return q.unit.ratio.div(q.value); // mpg -> L/100km
+ }
+ let b = q.value.mul(q.unit.ratio);
+ if (q.unit.offset) b = b.add(q.unit.offset);
+ return b;
+}
+
+export function fromBase(base: Decimal, unit: Unit): Decimal {
+ if (unit.reciprocal) {
+ if (base.isZero()) throw new EvalError("division by zero");
+ return unit.ratio.div(base);
+ }
+ let v = base;
+ if (unit.offset) v = v.sub(unit.offset);
+ return v.div(unit.ratio);
+}
+
+export function convertQ(q: Quantity, unit: Unit): Quantity {
+ if (!q.unit) return { ...q, unit };
+ if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
+ return qty(fromBase(toBase(q), unit), unit);
+}
+
+function addQ(a: Quantity, b: Quantity): Quantity {
+ return numericAdd(a, b, 1);
+}
+
+function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
+ if (a.unit && b.unit) {
+ if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
+ if (a.unit.offset || b.unit.offset) {
+ // temperatures: operate in the left unit's scale
+ const bInA = convertQ(b, a.unit);
+ return qty(sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value), a.unit);
+ }
+ const base = sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
+ return qty(fromBase(base, a.unit), a.unit);
+ }
+ const unit = a.unit ?? b.unit ?? null;
+ return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
+}
+
+function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value, ctx: EvalCtx): Value {
+ // ---- dates
+ if (l.kind === "date" || r.kind === "date") return evalDateArith(op, l, r);
+
+ // ---- percent combinations
+ if (l.kind === "percent" || r.kind === "percent") {
+ if (l.kind === "percent" && r.kind === "percent") {
+ switch (op) {
+ case "plus": return pct(l.value.add(r.value));
+ case "minus": return pct(l.value.sub(r.value));
+ case "mul": return pct(l.value.mul(r.value).div(100));
+ case "div": return qty(l.value.div(r.value));
+ default: throw new EvalError("bad percent op");
+ }
+ }
+ const q = (l.kind === "quantity" ? l : r) as Quantity;
+ const p = (l.kind === "percent" ? l : r).value;
+ switch (op) {
+ case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
+ case "minus":
+ if (l.kind === "percent") throw new EvalError("percent minus number");
+ return qty(q.value.mul(new Decimal(1).sub(p.div(100))), q.unit);
+ case "mul": return qty(q.value.mul(p).div(100), q.unit);
+ case "div":
+ if (l.kind === "percent") throw new EvalError("percent div number");
+ return qty(q.value.div(p.div(100)), q.unit);
+ case "pow": return qty(q.value.pow(p.div(100)), q.unit);
+ default: throw new EvalError("bad percent op");
+ }
+ }
+
+ const a = l as Quantity;
+ const b = r as Quantity;
+ switch (op) {
+ case "plus": return numericAdd(a, b, 1);
+ case "minus": return numericAdd(a, b, -1);
+ case "mul": {
+ if (a.unit && b.unit) {
+ const da = a.unit.dimension;
+ const db = b.unit.dimension;
+ const baseProduct = toBase(a).mul(toBase(b));
+ let targetId: string | null = null;
+ if (da === "length" && db === "length") targetId = `sq:${a.unit.id}`;
+ else if (da === "length" && db === "area") targetId = `cb:${a.unit.id}`;
+ else if (da === "area" && db === "length") targetId = `cb:${b.unit.id}`;
+ if (targetId) {
+ const u = ctx.reg.unitsById.get(targetId);
+ if (u) return qty(baseProduct.div(u.ratio), u);
+ }
+ return qty(a.value.mul(b.value), a.unit);
+ }
+ return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
+ }
+ case "div": {
+ if (b.value.isZero()) throw new EvalError("division by zero");
+ if (a.unit && b.unit && a.unit.dimension === b.unit.dimension) {
+ return qty(toBase(a).div(toBase(b)));
+ }
+ return qty(a.value.div(b.value), a.unit ?? null);
+ }
+ case "mod": {
+ if (b.value.isZero()) throw new EvalError("mod zero");
+ return qty(a.value.mod(b.value), a.unit ?? null);
+ }
+ case "pow": {
+ const result = a.value.pow(b.value);
+ if (a.unit?.dimension === "length" && !b.unit) {
+ const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null;
+ const u = id ? ctx.reg.unitsById.get(id) : null;
+ if (u) return qty(result, u);
+ }
+ return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null);
+ }
+ }
+}
+
+function evalBit(op: string, l: Value, r: Value): Value {
+ if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
+ const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
+ const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
+ if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
+ let out: bigint;
+ switch (op) {
+ case "band": out = a & b; break;
+ case "bor": out = a | b; break;
+ case "bxor": out = a ^ b; break;
+ case "shl": out = a << b; break;
+ case "shr": out = a >> b; break;
+ default: throw new EvalError("bad bit op");
+ }
+ const repr = l.repr !== "decimal" ? l.repr : r.repr;
+ return qty(new Decimal(out.toString()), null, repr);
+}
+
+function evalPctOp(op: string, l: Value, r: Value): Value {
+ const rq = r.kind === "quantity" ? r : null;
+ const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null;
+ if (rv === null) throw new EvalError("bad operand");
+ const unit = rq?.unit ?? null;
+
+ if (op === "of" || op === "off" || op === "on") {
+ const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
+ switch (op) {
+ case "of": return qty(rv.mul(p).div(100), unit);
+ case "off": return qty(rv.mul(new Decimal(1).sub(p.div(100))), unit);
+ case "on": return qty(rv.mul(new Decimal(1).add(p.div(100))), unit);
+ }
+ }
+ if (op === "as_pct_of" || op === "as_pct_off" || op === "as_pct_on") {
+ const lv = l.kind === "quantity" ? l.value : asScalar(l);
+ if (rv.isZero()) throw new EvalError("division by zero");
+ switch (op) {
+ case "as_pct_of": return pct(lv.div(rv).mul(100));
+ case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100));
+ case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
+ }
+ }
+ // "20% of what is 30"
+ const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
+ switch (op) {
+ case "of_what_is": return qty(rv.div(p.div(100)), unit);
+ case "off_what_is": {
+ const d = new Decimal(1).sub(p.div(100));
+ if (d.isZero()) throw new EvalError("division by zero");
+ return qty(rv.div(d), unit);
+ }
+ case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
+ }
+ throw new EvalError(`bad percent op ${op}`);
+}
+
+function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
+ switch (target.type) {
+ case "unit": {
+ if (v.kind === "percent") return pct(v.value); // "x in %" — keep
+ if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit");
+ if (!v.unit) return qty(v.value, target.unit);
+ return convertQ(v, target.unit);
+ }
+ case "currency": {
+ const unit = ctx.reg.makeCurrencyUnit(target.code);
+ if (!unit) throw new EvalError(`no rate for ${target.code}`);
+ if (v.kind !== "quantity") throw new EvalError("cannot convert to currency");
+ if (!v.unit) return qty(v.value, unit);
+ return convertQ(v, unit);
+ }
+ case "repr": {
+ if (v.kind !== "quantity") throw new EvalError("bad repr conversion");
+ return { ...v, repr: target.repr };
+ }
+ case "scale": {
+ if (v.kind !== "quantity") throw new EvalError("bad scale conversion");
+ return qty(v.value.div(target.mult), v.unit); // formatter shows plain number
+ }
+ case "tz": {
+ const zone = resolveZone(target.words);
+ if (!zone) throw new EvalError("unknown timezone");
+ if (v.kind !== "date") throw new EvalError("timezone needs a time");
+ return { ...v, timeZone: zone };
+ }
+ case "unix": {
+ if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
+ throw new EvalError("unix needs a date");
+ }
+ case "todate": {
+ if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number");
+ // heuristics: > 1e12 means milliseconds, else seconds
+ const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber();
+ return { kind: "date", ms, hasTime: true };
+ }
+ }
+}
+
+function evalDateWord(word: string): Value {
+ switch (word) {
+ case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
+ case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false };
+ case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, hasTime: false };
+ case "now": return { kind: "date", ms: Date.now(), hasTime: true };
+ case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
+ default: throw new EvalError(`bad date word ${word}`);
+ }
+}
+
+function evalDateArith(op: string, l: Value, r: Value): Value {
+ if (l.kind === "date" && r.kind === "date") {
+ if (op !== "minus") throw new EvalError("dates only subtract");
+ const days = new Decimal(l.ms - r.ms).div(86400_000);
+ return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
+ }
+ const date = (l.kind === "date" ? l : r) as Extract;
+ const span = l.kind === "date" ? r : l;
+ if (span.kind !== "quantity" || span.unit?.dimension !== "time") {
+ throw new EvalError("date arithmetic needs a time span");
+ }
+ if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
+ if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
+ const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
+ let ms: number;
+ if (isCalendarUnit(span.unit.id, amount)) {
+ ms = addToDate(date.ms, amount, span.unit.id);
+ } else {
+ ms = date.ms + span.value.mul(span.unit.ratio).mul(1000).toNumber() * (op === "minus" ? -1 : 1);
+ }
+ return { ...date, ms };
+}
+
+function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value {
+ const { lineValues, lineKinds, index } = ctx.line;
+ if (name === "prev") {
+ for (let i = index - 1; i >= 0; i--) {
+ const v = lineValues[i];
+ if (v) return v;
+ }
+ throw new EvalError("no previous result");
+ }
+ const block: Quantity[] = [];
+ for (let i = index - 1; i >= 0; i--) {
+ if (lineKinds[i] === "empty" || lineKinds[i] === "header") {
+ if (block.length > 0) break;
+ if (lineKinds[i] === "header") break;
+ continue; // skip empties directly above until the block starts
+ }
+ const v = lineValues[i];
+ if (v?.kind === "quantity") block.push(v);
+ }
+ if (block.length === 0) throw new EvalError("nothing to aggregate");
+ block.reverse();
+ let acc = block[0];
+ for (let i = 1; i < block.length; i++) {
+ const item = block[i];
+ try {
+ acc = numericAdd(acc, item, 1);
+ } catch {
+ // skip lines with incompatible dimensions, as Numi does
+ }
+ }
+ if (name === "avg") return qty(acc.value.div(block.length), acc.unit);
+ return acc;
+}
+
+function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
+ const custom = ctx.reg.customFuncs.get(name);
+ if (custom) return custom(args);
+
+ const x = args[0];
+ if (!x) throw new EvalError(`${name} needs an argument`);
+
+ // trig works on angles: degrees convert to radians
+ const angleArg = (): Decimal => {
+ if (x.kind === "quantity" && x.unit?.dimension === "angle") {
+ return x.value.mul(x.unit.ratio);
+ }
+ return asScalar(x);
+ };
+ const n = () => asScalar(x);
+
+ switch (name) {
+ case "sqrt": {
+ if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
+ const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
+ if (base) return qty(x.value.sqrt(), base);
+ }
+ return qty(n().sqrt());
+ }
+ case "cbrt": return qty(n().cbrt());
+ case "sin": return qty(Decimal.sin(angleArg()));
+ case "cos": return qty(Decimal.cos(angleArg()));
+ case "tan": return qty(Decimal.tan(angleArg()));
+ case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
+ case "asin": case "arcsin": return qty(Decimal.asin(n()));
+ case "acos": case "arccos": return qty(Decimal.acos(n()));
+ case "atan": case "arctan": return qty(Decimal.atan(n()));
+ case "sinh": return qty(Decimal.sinh(n()));
+ case "cosh": return qty(Decimal.cosh(n()));
+ case "tanh": return qty(Decimal.tanh(n()));
+ case "ln": return qty(Decimal.ln(n()));
+ case "lg": case "log": return qty(Decimal.log10(n()));
+ case "log2": return qty(Decimal.log2(n()));
+ case "exp": return qty(Decimal.exp(n()));
+ case "abs": return x.kind === "quantity" ? { ...x, value: x.value.abs() } : qty(n().abs());
+ case "round": return x.kind === "quantity" ? { ...x, value: x.value.round() } : qty(n().round());
+ case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
+ case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
+ case "fact": case "factorial": return qty(factorial(n()));
+ default: throw new EvalError(`unknown function ${name}`);
+ }
+}
+
+function factorial(n: Decimal): Decimal {
+ if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
+ let acc = new Decimal(1);
+ for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
+ return acc;
+}
diff --git a/src/engine/extraunits.ts b/src/engine/extraunits.ts
new file mode 100644
index 0000000..18787ae
--- /dev/null
+++ b/src/engine/extraunits.ts
@@ -0,0 +1,98 @@
+// Speed, pressure, energy, power, frequency and fuel consumption units,
+// plus cryptocurrencies. Phrases inline (en + ru).
+
+export interface ExtraUnitDef {
+ id: string;
+ dimension: "speed" | "pressure" | "energy" | "power" | "frequency" | "fuel";
+ /** multiplier to the dimension base: m/s, Pa, J, W, Hz, L/100km */
+ ratio: string;
+ /** reciprocal unit: base = ratio / value (mpg) */
+ reciprocal?: boolean;
+ format: string;
+ /** comma-separated, matched case-insensitively */
+ phrases: string;
+ /** matched case-sensitively (symbols like kW vs KW ambiguity) */
+ symbols?: string;
+}
+
+export const EXTRA_UNITS: ExtraUnitDef[] = [
+ // ---- speed (base: m/s)
+ { id: "mps", dimension: "speed", ratio: "1", format: "m/s", phrases: "m/s,meter per second,meters per second,м/с,метров в секунду" },
+ { id: "kmh", dimension: "speed", ratio: String(1 / 3.6), format: "km/h", phrases: "km/h,kmh,kph,kilometers per hour,kilometres per hour,км/ч,кмч,километров в час" },
+ { id: "mph", dimension: "speed", ratio: "0.44704", format: "mph", phrases: "mph,miles per hour,миль в час" },
+ { id: "knot", dimension: "speed", ratio: "0.514444444", format: "kn", phrases: "knot,knots,kn,узел,узла,узлов" },
+ { id: "fps", dimension: "speed", ratio: "0.3048", format: "ft/s", phrases: "ft/s,feet per second,фут/с" },
+
+ // ---- pressure (base: Pa)
+ { id: "pascal", dimension: "pressure", ratio: "1", format: "Pa", phrases: "pascal,pascals,паскаль,паскалей", symbols: "Pa" },
+ { id: "kpa", dimension: "pressure", ratio: "1000", format: "kPa", phrases: "kilopascal,kilopascals,килопаскаль", symbols: "kPa" },
+ { id: "mpa", dimension: "pressure", ratio: "1e6", format: "MPa", phrases: "megapascal,megapascals,мегапаскаль", symbols: "MPa" },
+ { id: "hpa", dimension: "pressure", ratio: "100", format: "hPa", phrases: "hectopascal,hectopascals,гектопаскаль", symbols: "hPa" },
+ { id: "bar", dimension: "pressure", ratio: "1e5", format: "bar", phrases: "bar,bars,бар,бара,баров" },
+ { id: "mbar", dimension: "pressure", ratio: "100", format: "mbar", phrases: "mbar,millibar,millibars,миллибар" },
+ { id: "atm", dimension: "pressure", ratio: "101325", format: "atm", phrases: "atm,atmosphere,atmospheres,атмосфер,атмосфера,атмосферы" },
+ { id: "psi", dimension: "pressure", ratio: "6894.757293168", format: "psi", phrases: "psi" },
+ { id: "mmhg", dimension: "pressure", ratio: "133.322387415", format: "mmHg", phrases: "mmhg,mm hg,torr,мм рт ст,мм рт. ст.,миллиметров ртутного столба", symbols: "mmHg" },
+
+ // ---- energy (base: J)
+ { id: "joule", dimension: "energy", ratio: "1", format: "J", phrases: "joule,joules,джоуль,джоулей,джоуля", symbols: "J" },
+ { id: "kj", dimension: "energy", ratio: "1000", format: "kJ", phrases: "kilojoule,kilojoules,килоджоуль,килоджоулей", symbols: "kJ" },
+ { id: "mj", dimension: "energy", ratio: "1e6", format: "MJ", phrases: "megajoule,megajoules,мегаджоуль", symbols: "MJ" },
+ { id: "cal", dimension: "energy", ratio: "4.184", format: "cal", phrases: "calorie,calories,калория,калорий,калории,cal" },
+ { id: "kcal", dimension: "energy", ratio: "4184", format: "kcal", phrases: "kcal,kilocalorie,kilocalories,ккал,килокалорий,килокалория" },
+ { id: "wh", dimension: "energy", ratio: "3600", format: "Wh", phrases: "watt hour,watt hours,ватт-час,ватт-часов", symbols: "Wh" },
+ { id: "kwh", dimension: "energy", ratio: "3.6e6", format: "kWh", phrases: "kwh,kilowatt hour,kilowatt hours,киловатт-час,киловатт-часов,квтч,квт-ч", symbols: "kWh" },
+ { id: "mwh", dimension: "energy", ratio: "3.6e9", format: "MWh", phrases: "megawatt hour,megawatt hours,мегаватт-час", symbols: "MWh" },
+
+ // ---- power (base: W)
+ { id: "watt", dimension: "power", ratio: "1", format: "W", phrases: "watt,watts,ватт,ваттов", symbols: "W" },
+ { id: "kw", dimension: "power", ratio: "1000", format: "kW", phrases: "kilowatt,kilowatts,киловатт,киловатта,квт", symbols: "kW" },
+ { id: "mw", dimension: "power", ratio: "1e6", format: "MW", phrases: "megawatt,megawatts,мегаватт", symbols: "MW" },
+ { id: "gw", dimension: "power", ratio: "1e9", format: "GW", phrases: "gigawatt,gigawatts,гигаватт", symbols: "GW" },
+ { id: "hp", dimension: "power", ratio: "745.69987158", format: "hp", phrases: "hp,horsepower,horse power" },
+ { id: "hp_metric", dimension: "power", ratio: "735.49875", format: "л.с.", phrases: "л.с.,лс,лошадиных сил,лошадиная сила,лошадиные силы,metric horsepower" },
+
+ // ---- frequency (base: Hz)
+ { id: "hz", dimension: "frequency", ratio: "1", format: "Hz", phrases: "hertz,герц,герца", symbols: "Hz" },
+ { id: "khz", dimension: "frequency", ratio: "1e3", format: "kHz", phrases: "kilohertz,килогерц", symbols: "kHz" },
+ { id: "mhz", dimension: "frequency", ratio: "1e6", format: "MHz", phrases: "megahertz,мегагерц", symbols: "MHz" },
+ { id: "ghz", dimension: "frequency", ratio: "1e9", format: "GHz", phrases: "gigahertz,гигагерц", symbols: "GHz" },
+ { id: "rpm", dimension: "frequency", ratio: String(1 / 60), format: "rpm", phrases: "rpm,revolutions per minute,оборотов в минуту,об/мин" },
+
+ // ---- fuel consumption (base: L/100km; mpg is reciprocal)
+ { id: "l100km", dimension: "fuel", ratio: "1", format: "L/100km", phrases: "l/100km,l/100 km,liters per 100 km,litres per 100 km,л/100км,л/100 км,литров на 100 км,литров на сотню" },
+ { id: "mpg", dimension: "fuel", ratio: "235.214583", reciprocal: true, format: "mpg", phrases: "mpg,miles per gallon,миль на галлон" },
+ { id: "mpg_imp", dimension: "fuel", ratio: "282.480936", reciprocal: true, format: "mpg (imp)", phrases: "imperial mpg,mpg imperial,uk mpg" },
+];
+
+/** Cryptocurrencies beyond BTC. code -> CoinGecko id + phrases + USD snapshot price. */
+export interface CryptoDef {
+ code: string;
+ geckoId: string;
+ phrases: string;
+ /** approximate USD price for the offline snapshot */
+ snapshotUsd: number;
+}
+
+export const CRYPTO: CryptoDef[] = [
+ { code: "ETH", geckoId: "ethereum", phrases: "ethereum,ether,eth,эфир,эфириум", snapshotUsd: 2600 },
+ { code: "SOL", geckoId: "solana", phrases: "solana,солана", snapshotUsd: 160 },
+ { code: "BNB", geckoId: "binancecoin", phrases: "bnb,binance coin", snapshotUsd: 640 },
+ { code: "XRP", geckoId: "ripple", phrases: "xrp,ripple,рипл", snapshotUsd: 2.2 },
+ { code: "ADA", geckoId: "cardano", phrases: "cardano,ada,кардано", snapshotUsd: 0.7 },
+ { code: "DOGE", geckoId: "dogecoin", phrases: "dogecoin,doge,доджкоин,дож", snapshotUsd: 0.18 },
+ { code: "TRX", geckoId: "tron", phrases: "tron,trx,трон", snapshotUsd: 0.27 },
+ { code: "TON", geckoId: "the-open-network", phrases: "toncoin,тонкоин", snapshotUsd: 3.5 },
+ { code: "DOT", geckoId: "polkadot", phrases: "polkadot,полкадот", snapshotUsd: 4.5 },
+ { code: "LTC", geckoId: "litecoin", phrases: "litecoin,ltc,лайткоин", snapshotUsd: 90 },
+ { code: "AVAX", geckoId: "avalanche-2", phrases: "avalanche,avax", snapshotUsd: 25 },
+ { code: "LINK", geckoId: "chainlink", phrases: "chainlink,чейнлинк", snapshotUsd: 15 },
+ { code: "UNI", geckoId: "uniswap", phrases: "uniswap,юнисвап", snapshotUsd: 8 },
+ { code: "XLM", geckoId: "stellar", phrases: "stellar,xlm,стеллар", snapshotUsd: 0.3 },
+ { code: "XMR", geckoId: "monero", phrases: "monero,xmr,монеро", snapshotUsd: 170 },
+ { code: "ATOM", geckoId: "cosmos", phrases: "cosmos,космос", snapshotUsd: 5 },
+ { code: "BCH", geckoId: "bitcoin-cash", phrases: "bitcoin cash,bch", snapshotUsd: 450 },
+ { code: "NEAR", geckoId: "near", phrases: "near protocol", snapshotUsd: 3 },
+ { code: "USDT", geckoId: "tether", phrases: "tether,usdt,юсдт", snapshotUsd: 1 },
+ { code: "USDC", geckoId: "usd-coin", phrases: "usdc", snapshotUsd: 1 },
+];
diff --git a/src/engine/formatter.ts b/src/engine/formatter.ts
new file mode 100644
index 0000000..12a8b1e
--- /dev/null
+++ b/src/engine/formatter.ts
@@ -0,0 +1,116 @@
+// Turns values into display strings: unit/currency formats plus
+// precision and grouping settings.
+import { Decimal, EngineSettings, Value } from "./types";
+
+function group(intPart: string, sep: string): string {
+ if (!sep) return intPart;
+ const neg = intPart.startsWith("-");
+ const digits = neg ? intPart.slice(1) : intPart;
+ if (digits.length < 4) return intPart;
+ const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
+ return (neg ? "-" : "") + grouped;
+}
+
+export function formatNumber(v: Decimal, s: EngineSettings): string {
+ const rounded = v.toDecimalPlaces(s.precision);
+ const str = rounded.toString();
+ if (str.includes("e") || str.includes("E")) return str;
+ const [int, frac] = str.split(".");
+ const intG = group(int, s.groupSeparator);
+ return frac !== undefined ? intG + s.decimalSeparator + frac : intG;
+}
+
+export function formatValue(v: Value, s: EngineSettings): string {
+ if (v.kind === "percent") {
+ return formatNumber(v.value, s) + "%";
+ }
+
+ if (v.kind === "date") {
+ const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
+ if (v.timeOnly) {
+ return new Intl.DateTimeFormat(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
+ }
+ if (v.hasTime) {
+ return new Intl.DateTimeFormat(undefined, {
+ ...opts, hour: "2-digit", minute: "2-digit", hour12: false,
+ day: "numeric", month: "short", year: "numeric",
+ }).format(v.ms);
+ }
+ return new Intl.DateTimeFormat(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
+ }
+
+ // quantity
+ if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
+ const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
+ const neg = int.isNegative();
+ const abs = int.abs();
+ const prefix = v.repr === "hex" ? "0x" : v.repr === "binary" ? "0b" : "0o";
+ const radix = v.repr === "hex" ? 16 : v.repr === "binary" ? 2 : 8;
+ const digits = BigInt(abs.toFixed(0)).toString(radix);
+ return (neg ? "-" : "") + prefix + (v.repr === "hex" ? digits.toUpperCase() : digits);
+ }
+ if (v.repr === "scientific") {
+ return v.value.toExponential(Math.min(s.precision, 10)).replace(/e\+?/, "e");
+ }
+ if (v.repr === "plain") {
+ return v.value.toString(); // no grouping (unix timestamps)
+ }
+ if (v.repr === "fraction") {
+ return formatFraction(v.value);
+ }
+ if (v.repr === "roman") {
+ return formatRoman(v.value) ?? formatNumber(v.value, s);
+ }
+
+ const num = formatNumber(v.value, s);
+ if (!v.unit) return num;
+ return withUnit(num, v.unit.format);
+}
+
+function withUnit(num: string, f: string): string {
+ if (f.includes("{}")) {
+ return f.replace("{}", num); // currency template
+ }
+ // suffix units: letters get a space ("12.7 cm"), symbols attach ("5″", "25°C")
+ return /^[\p{L}]/u.test(f) && !f.startsWith("°") ? `${num} ${f}` : `${num}${f}`;
+}
+
+/** Best rational approximation via continued fractions: 0.75 -> "3/4". */
+function formatFraction(v: Decimal): string {
+ const neg = v.isNegative();
+ const x = v.abs().toNumber();
+ if (!Number.isFinite(x)) return v.toString();
+ let h1 = 1, h0 = 0, k1 = 0, k0 = 1, b = x;
+ for (let i = 0; i < 40; i++) {
+ const a = Math.floor(b);
+ [h1, h0] = [a * h1 + h0, h1];
+ [k1, k0] = [a * k1 + k0, k1];
+ if (k1 > 10000) break;
+ if (Math.abs(x - h1 / k1) < 1e-12) break;
+ const frac = b - a;
+ if (frac < 1e-12) break;
+ b = 1 / frac;
+ }
+ const sign = neg ? "-" : "";
+ if (k1 === 1) return sign + String(h1);
+ const whole = Math.floor(h1 / k1);
+ const rem = h1 - whole * k1;
+ return whole > 0 ? `${sign}${whole} ${rem}/${k1}` : `${sign}${rem}/${k1}`;
+}
+
+function formatRoman(v: Decimal): string | null {
+ if (!v.isInteger() || v.lt(1) || v.gt(3999)) return null;
+ let n = v.toNumber();
+ const table: Array<[number, string]> = [
+ [1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
+ [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
+ ];
+ let out = "";
+ for (const [val, sym] of table) {
+ while (n >= val) {
+ out += sym;
+ n -= val;
+ }
+ }
+ return out;
+}
diff --git a/src/engine/index.ts b/src/engine/index.ts
new file mode 100644
index 0000000..ea69f41
--- /dev/null
+++ b/src/engine/index.ts
@@ -0,0 +1,217 @@
+// Public facade: evaluates whole documents line by line and exposes the
+// extension API (numi.addUnit / addFunction / setVariable).
+import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, defaultSettings, qty } from "./types";
+import { Registry, buildRegistry, Completion } from "./registry";
+import { tokenize, Token } from "./tokenizer";
+import { parseLine } from "./parser";
+import { evaluate, EvalCtx, toBase, fromBase } from "./evaluator";
+import { formatValue } from "./formatter";
+import snapshot from "./rates-snapshot.json";
+import { CRYPTO } from "./extraunits";
+
+const cryptoSnapshot: Record = {};
+for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd;
+
+export interface LineResult {
+ /** formatted result, null when the line has none (text, comment, header, error) */
+ text: string | null;
+ value: Value | null;
+ kind: "empty" | "header" | "comment" | "normal";
+ /** tokens, for syntax highlighting */
+ tokens: Token[];
+ /** offset of "//" comment start within the line, if any */
+ commentStart: number | null;
+}
+
+export interface ExtensionUnitSpec {
+ id: string;
+ phrases: string;
+ baseUnitId: string;
+ format?: string;
+ ratio: number;
+}
+
+export interface ExtensionValue {
+ double: number;
+ unitId?: string;
+}
+
+export class SumEngine {
+ reg: Registry;
+ settings: EngineSettings;
+ /** variables defined by extensions, persist across evaluations */
+ private globals = new Map();
+
+ constructor(settings: Partial = {}) {
+ this.settings = { ...defaultSettings, ...settings };
+ this.reg = buildRegistry();
+ this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
+ }
+
+ setRates(rates: Record): void {
+ // snapshot fills codes the live API doesn't return (e.g. crypto when CoinGecko fails)
+ this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates });
+ }
+
+ updateSettings(patch: Partial): void {
+ this.settings = { ...this.settings, ...patch };
+ }
+
+ completions(): Completion[] {
+ return this.reg.completions;
+ }
+
+ evaluateDocument(text: string): LineResult[] {
+ const lines = text.split("\n");
+ const results: LineResult[] = [];
+ const vars = new Map(this.globals);
+ const lineValues: (Value | null)[] = [];
+ const lineKinds: ("empty" | "header" | "normal")[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const rawLine = lines[i];
+ const commentStart = rawLine.indexOf("//");
+ const line = commentStart >= 0 ? rawLine.slice(0, commentStart) : rawLine;
+ const trimmed = line.trim();
+
+ if (trimmed.length === 0) {
+ const kind = commentStart >= 0 ? "comment" : "empty";
+ results.push({ text: null, value: null, kind, tokens: [], commentStart: commentStart >= 0 ? commentStart : null });
+ lineValues.push(null);
+ lineKinds.push("empty");
+ continue;
+ }
+ if (trimmed.startsWith("#")) {
+ results.push({ text: null, value: null, kind: "header", tokens: [], commentStart: commentStart >= 0 ? commentStart : null });
+ lineValues.push(null);
+ lineKinds.push("header");
+ continue;
+ }
+
+ const tokens = tokenize(line, this.reg);
+ const knownVars = new Set(vars.keys());
+ const parsed = parseLine(tokens, knownVars);
+
+ let value: Value | null = null;
+ if (parsed.expr) {
+ const ctx: EvalCtx = {
+ reg: this.reg,
+ vars,
+ line: { lineValues, lineKinds, index: i },
+ };
+ try {
+ value = evaluate(parsed.expr, ctx);
+ if (parsed.assign) vars.set(parsed.assign, value);
+ } catch (e) {
+ if (!(e instanceof EvalError)) throw e;
+ value = null;
+ }
+ }
+
+ lineValues.push(value);
+ lineKinds.push("normal");
+ results.push({
+ text: value ? formatValue(value, this.settings) : null,
+ value,
+ kind: "normal",
+ tokens,
+ commentStart: commentStart >= 0 ? commentStart : null,
+ });
+ }
+ return results;
+ }
+
+ /** Evaluate a single expression (used by the extension runtime and tests). */
+ evaluateExpression(text: string): Value | null {
+ const r = this.evaluateDocument(text);
+ return r[r.length - 1]?.value ?? null;
+ }
+
+ /**
+ * Grand total of the sheet for the status bar: sums every line result
+ * compatible with the first united result's dimension (converted to its
+ * unit). Returns null when fewer than two lines contribute.
+ */
+ totalOf(results: LineResult[]): string | null {
+ const qs = results
+ .map((r) => r.value)
+ .filter((v): v is Quantity => v !== null && v.kind === "quantity");
+ if (qs.length < 2) return null;
+ const anchor = qs.find((q) => q.unit) ?? qs[0];
+ let acc = new Decimal(0);
+ let count = 0;
+ for (const q of qs) {
+ if (anchor.unit && q.unit && q.unit.dimension === anchor.unit.dimension) {
+ acc = acc.add(toBase(q));
+ count++;
+ } else if (!anchor.unit && !q.unit) {
+ acc = acc.add(q.value);
+ count++;
+ }
+ }
+ if (count < 2) return null;
+ const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
+ return formatValue(qty(value, anchor.unit ?? null), this.settings);
+ }
+
+ // ---------- extension API (numi.*)
+
+ setVariable(name: string, value: number | ExtensionValue): void {
+ this.globals.set(name, this.toValue(value));
+ }
+
+ addUnit(spec: ExtensionUnitSpec): void {
+ const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
+ if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
+ const unit: Unit = {
+ id: spec.id,
+ dimension: base.dimension,
+ ratio: base.ratio.mul(spec.ratio),
+ ...(base.offset ? { offset: base.offset } : {}),
+ format: spec.format ?? spec.id,
+ };
+ this.reg.unitsById.set(unit.id, unit);
+ for (const p of spec.phrases.split(",")) {
+ const phrase = p.trim();
+ if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false });
+ }
+ this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" });
+ }
+
+ addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void {
+ const name = spec.id;
+ this.reg.customFuncs.set(name, (args: Value[]) => {
+ const mapped = args.map((a) => this.fromValue(a));
+ return this.toValue(fn(mapped));
+ });
+ for (const p of (spec.phrases || spec.id).split(",")) {
+ const phrase = p.trim();
+ if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false });
+ }
+ this.reg.completions.push({ label: name, type: "function", detail: "extension" });
+ }
+
+ private currencyBase(code: string): Unit | null {
+ return this.reg.currencyCodes.has(code) ? this.reg.makeCurrencyUnit(code) : null;
+ }
+
+ private toValue(v: number | ExtensionValue): Value {
+ if (typeof v === "number") return qty(v);
+ const unit = v.unitId
+ ? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null
+ : null;
+ return qty(new Decimal(v.double), unit);
+ }
+
+ private fromValue(v: Value): ExtensionValue {
+ if (v.kind === "quantity") {
+ return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
+ }
+ if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
+ return { double: v.ms };
+ }
+}
+
+export { formatValue } from "./formatter";
+export type { Value, EngineSettings } from "./types";
+export type { Token } from "./tokenizer";
diff --git a/src/engine/lexer.ts b/src/engine/lexer.ts
new file mode 100644
index 0000000..4eed6e9
--- /dev/null
+++ b/src/engine/lexer.ts
@@ -0,0 +1,79 @@
+// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
+// tokenizer and the phrase registry so both segment text the same way.
+import { Decimal, NumeralRepr } from "./types";
+
+export interface Lex {
+ type: "num" | "word" | "sym";
+ raw: string;
+ start: number;
+ end: number;
+ value?: Decimal;
+ repr?: NumeralRepr;
+}
+
+const WORD_RE = /^[\p{L}_]+/u;
+// Order matters: grouped thousands first, then plain decimal/exponent, then leading-dot.
+const NUM_RE = /^(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+)/;
+
+export function lexLine(text: string): Lex[] {
+ const out: Lex[] = [];
+ let i = 0;
+ while (i < text.length) {
+ const ch = text[i];
+ if (ch === " " || ch === "\t" || ch === " ") {
+ i++;
+ continue;
+ }
+ const rest = text.slice(i);
+
+ if (ch >= "0" && ch <= "9") {
+ const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
+ if (hex) {
+ out.push({ type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" });
+ i += hex[0].length;
+ continue;
+ }
+ const bin = /^0[bB][01]+/.exec(rest);
+ if (bin) {
+ out.push({ type: "num", raw: bin[0], start: i, end: i + bin[0].length, value: new Decimal(parseInt(bin[0].slice(2), 2)), repr: "binary" });
+ i += bin[0].length;
+ continue;
+ }
+ const oct = /^0[oO][0-7]+/.exec(rest);
+ if (oct) {
+ out.push({ type: "num", raw: oct[0], start: i, end: i + oct[0].length, value: new Decimal(parseInt(oct[0].slice(2), 8)), repr: "octal" });
+ i += oct[0].length;
+ continue;
+ }
+ }
+ const num = NUM_RE.exec(rest);
+ if (num && (ch >= "0" && ch <= "9")) {
+ const raw = num[0];
+ out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(raw.replace(/,/g, "")), repr: "decimal" });
+ i += raw.length;
+ continue;
+ }
+ if (ch === "." && /^\.\d/.test(rest)) {
+ const m = /^\.\d+/.exec(rest)!;
+ out.push({ type: "num", raw: m[0], start: i, end: i + m[0].length, value: new Decimal(m[0]), repr: "decimal" });
+ i += m[0].length;
+ continue;
+ }
+
+ const word = WORD_RE.exec(rest);
+ if (word) {
+ out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length });
+ i += word[0].length;
+ continue;
+ }
+
+ out.push({ type: "sym", raw: ch, start: i, end: i + 1 });
+ i++;
+ }
+ return out;
+}
+
+/** Lexemes of a vocabulary phrase (positions are irrelevant). */
+export function lexPhrase(phrase: string): string[] {
+ return lexLine(phrase).map((l) => l.raw);
+}
diff --git a/src/engine/parser.ts b/src/engine/parser.ts
new file mode 100644
index 0000000..7de7896
--- /dev/null
+++ b/src/engine/parser.ts
@@ -0,0 +1,389 @@
+// Noise-tolerant Pratt parser. Unknown words are dropped (free text around
+// math is allowed), except right after a conversion word — those may name
+// a timezone ("time in New York").
+import { Decimal, NumeralRepr, Unit } from "./types";
+import { Token } from "./tokenizer";
+import { PctOp, DateWord, BitOp } from "./registry";
+
+export type ConvTarget =
+ | { type: "unit"; unit: Unit }
+ | { type: "currency"; code: string }
+ | { type: "repr"; repr: NumeralRepr }
+ | { type: "tz"; words: string[] }
+ | { type: "scale"; mult: Decimal; label: string }
+ | { type: "unix" }
+ | { type: "todate" };
+
+export type Node =
+ | { k: "num"; v: Decimal; repr: NumeralRepr }
+ | { k: "const"; name: "pi" | "e" }
+ | { k: "var"; name: string }
+ | { k: "date"; word: DateWord }
+ | { k: "agg"; name: "sum" | "avg" | "prev" }
+ | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
+ | { k: "bit"; op: BitOp; l: Node; r: Node }
+ | { k: "pctop"; op: PctOp; l: Node; r: Node }
+ | { k: "neg"; x: Node }
+ | { k: "pct"; x: Node }
+ | { k: "fact"; x: Node }
+ | { k: "unit"; x: Node; unit: Unit }
+ | { k: "curr"; x: Node; code: string }
+ | { k: "scale"; x: Node; mult: Decimal; label: string }
+ | { k: "conv"; x: Node; target: ConvTarget }
+ | { k: "call"; name: string; args: Node[] }
+ | { k: "seq"; items: Node[] };
+
+export interface ParsedLine {
+ assign?: string;
+ expr: Node | null;
+}
+
+export function parseLine(tokens: Token[], knownVars: Set): ParsedLine {
+ let assign: string | undefined;
+ let toks = tokens;
+
+ // `x = expr` / `x is expr`
+ if (toks.length >= 2 && toks[0].t === "word" && toks[1].t === "assign") {
+ assign = toks[0].raw;
+ toks = toks.slice(2);
+ }
+
+ // Noise filtering: drop junk and unknown words, but keep words that follow
+ // a conversion operator (potential timezone names).
+ const filtered: Token[] = [];
+ let afterConv = false;
+ for (const tk of toks) {
+ if (tk.t === "junk") continue;
+ if (tk.t === "word") {
+ if (knownVars.has(tk.raw) || afterConv) filtered.push(tk);
+ continue;
+ }
+ afterConv = tk.t === "conv";
+ filtered.push(tk);
+ }
+
+ const p = new Parser(filtered, knownVars);
+ return { assign, expr: p.parseSeq() };
+}
+
+class Parser {
+ private i = 0;
+ constructor(private toks: Token[], private vars: Set) {}
+
+ private peek(): Token | undefined {
+ return this.toks[this.i];
+ }
+ private next(): Token | undefined {
+ return this.toks[this.i++];
+ }
+
+ /**
+ * Line level: a sequence of expressions with line-wide conversions.
+ * "2 hours 30 minutes in minutes" converts the merged sum, not the last item.
+ */
+ parseSeq(): Node | null {
+ let items: Node[] = [];
+ const mk = (): Node => (items.length === 1 ? items[0] : { k: "seq", items });
+ while (this.i < this.toks.length) {
+ const tk = this.peek()!;
+ if (tk.t === "conv" && items.length > 0) {
+ const save = this.i;
+ this.i++;
+ const target = this.parseTarget();
+ if (target) {
+ items = [{ k: "conv", x: mk(), target }];
+ continue;
+ }
+ this.i = save + 1; // drop the conversion word, it was noise
+ continue;
+ }
+ if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) && items.length > 0) {
+ this.i++;
+ const r = this.parseAdd();
+ if (r) {
+ items = [{ k: "pctop", op: tk.op, l: mk(), r }];
+ continue;
+ }
+ continue;
+ }
+ const before = this.i;
+ const node = this.parseBit();
+ if (node) items.push(node);
+ if (this.i === before) this.i++; // skip unparsable token
+ }
+ if (items.length === 0) return null;
+ return mk();
+ }
+
+ /** bitwise level (one flat precedence, left-assoc): & | xor << >> */
+ private parseBit(): Node | null {
+ let l = this.parseAdd();
+ if (!l) return null;
+ for (;;) {
+ const tk = this.peek();
+ if (tk?.t !== "bitop") break;
+ const save = this.i;
+ this.i++;
+ const r = this.parseAdd();
+ if (!r) {
+ this.i = save;
+ break;
+ }
+ l = { k: "bit", op: tk.op, l, r };
+ }
+ return l;
+ }
+
+ /** conversion level: `x in cm`, `x as a % of y`, `x of what is y` */
+ private parseConv(): Node | null {
+ let l = this.parseBit();
+ if (!l) return null;
+ for (;;) {
+ const tk = this.peek();
+ if (tk?.t === "conv") {
+ const save = this.i;
+ this.i++;
+ const target = this.parseTarget();
+ if (!target) {
+ this.i = save;
+ break;
+ }
+ l = { k: "conv", x: l, target };
+ } else if (tk?.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
+ this.i++;
+ const r = this.parseAdd();
+ if (!r) break;
+ l = { k: "pctop", op: tk.op, l, r };
+ } else {
+ break;
+ }
+ }
+ return l;
+ }
+
+ private parseTarget(): ConvTarget | null {
+ const tk = this.peek();
+ if (!tk) return null;
+ if (tk.t === "unit") {
+ this.i++;
+ return { type: "unit", unit: tk.unit };
+ }
+ if (tk.t === "currency") {
+ this.i++;
+ return { type: "currency", code: tk.code };
+ }
+ if (tk.t === "repr") {
+ this.i++;
+ return { type: "repr", repr: tk.repr };
+ }
+ if (tk.t === "special") {
+ this.i++;
+ return tk.name === "unix" ? { type: "unix" } : { type: "todate" };
+ }
+ if (tk.t === "scale") {
+ this.i++;
+ return { type: "scale", mult: tk.mult, label: "" };
+ }
+ if (tk.t === "date" && tk.word === "local") {
+ this.i++;
+ return { type: "tz", words: ["local"] };
+ }
+ if (tk.t === "word") {
+ const words: string[] = [];
+ while (this.peek()?.t === "word" && words.length < 4) {
+ words.push((this.next() as Token & { raw: string }).raw);
+ }
+ if (words.length > 0) return { type: "tz", words };
+ }
+ return null;
+ }
+
+ private parseAdd(): Node | null {
+ let l = this.parseMul();
+ if (!l) return null;
+ for (;;) {
+ const tk = this.peek();
+ if (tk?.t === "op" && (tk.op === "plus" || tk.op === "minus")) {
+ const save = this.i;
+ this.i++;
+ const r = this.parseMul();
+ if (!r) {
+ this.i = save;
+ break;
+ }
+ l = { k: "bin", op: tk.op, l, r };
+ } else {
+ break;
+ }
+ }
+ return l;
+ }
+
+ private parseMul(): Node | null {
+ let l = this.parseUnary();
+ if (!l) return null;
+ for (;;) {
+ const tk = this.peek();
+ if (tk?.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) {
+ const save = this.i;
+ this.i++;
+ const r = this.parseUnary();
+ if (!r) {
+ this.i = save;
+ break;
+ }
+ l = { k: "bin", op: tk.op, l, r };
+ } else if (tk?.t === "pctop" && (tk.op === "of" || tk.op === "off" || tk.op === "on")) {
+ // of/off/on only make sense with a percent on the left ("20% of x");
+ // otherwise the word is prose noise ("5 on coffee")
+ if (!(l.k === "pct" || l.k === "var" || l.k === "pctop")) {
+ this.i++;
+ continue;
+ }
+ const save = this.i;
+ this.i++;
+ const r = this.parseUnary();
+ if (!r) {
+ this.i = save;
+ break;
+ }
+ l = { k: "pctop", op: tk.op, l, r };
+ } else if (
+ tk &&
+ (tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw)))
+ ) {
+ // implicit multiplication: 2(3+4), 2pi, 2x
+ const save = this.i;
+ const r = this.parseUnary();
+ if (!r) {
+ this.i = save;
+ break;
+ }
+ l = { k: "bin", op: "mul", l, r };
+ } else {
+ break;
+ }
+ }
+ return l;
+ }
+
+ private parseUnary(): Node | null {
+ const tk = this.peek();
+ if (tk?.t === "op" && tk.op === "minus") {
+ this.i++;
+ const x = this.parseUnary();
+ return x ? { k: "neg", x } : null;
+ }
+ if (tk?.t === "op" && tk.op === "plus") {
+ this.i++;
+ return this.parseUnary();
+ }
+ return this.parsePostfix();
+ }
+
+ private parsePostfix(): Node | null {
+ let x = this.parsePrimary();
+ if (!x) return null;
+ for (;;) {
+ const tk = this.peek();
+ if (!tk) break;
+ if (tk.t === "percent") {
+ this.i++;
+ x = { k: "pct", x };
+ } else if (tk.t === "bang") {
+ this.i++;
+ x = { k: "fact", x };
+ } else if (tk.t === "unit") {
+ this.i++;
+ x = { k: "unit", x, unit: tk.unit };
+ } else if (tk.t === "currency") {
+ this.i++;
+ x = { k: "curr", x, code: tk.code };
+ } else if (tk.t === "scale") {
+ this.i++;
+ x = { k: "scale", x, mult: tk.mult, label: "" };
+ } else if (tk.t === "op" && tk.op === "pow") {
+ this.i++;
+ const r = this.parseUnary();
+ if (!r) break;
+ x = { k: "bin", op: "pow", l: x, r };
+ } else {
+ break;
+ }
+ }
+ return x;
+ }
+
+ private parsePrimary(): Node | null {
+ const tk = this.peek();
+ if (!tk) return null;
+ switch (tk.t) {
+ case "num":
+ this.i++;
+ return { k: "num", v: tk.v, repr: tk.repr };
+ case "const":
+ this.i++;
+ return { k: "const", name: tk.name };
+ case "date":
+ this.i++;
+ return { k: "date", word: tk.word };
+ case "agg":
+ this.i++;
+ return { k: "agg", name: tk.name };
+ case "word":
+ if (this.vars.has(tk.raw)) {
+ this.i++;
+ return { k: "var", name: tk.raw };
+ }
+ return null;
+ case "func": {
+ this.i++;
+ const name = tk.name;
+ if (this.peek()?.t === "lparen") {
+ this.i++;
+ const args: Node[] = [];
+ for (;;) {
+ const arg = this.parseConv();
+ if (arg) args.push(arg);
+ const nx = this.peek();
+ if (nx?.t === "semicolon") {
+ this.i++;
+ continue;
+ }
+ if (nx?.t === "rparen") {
+ this.i++;
+ break;
+ }
+ if (!nx) break;
+ if (!arg) {
+ this.i++; // skip stray token inside call
+ }
+ }
+ return { k: "call", name, args };
+ }
+ // `sqrt 16` / `square root of 16` — the "of" is decorative
+ const nx = this.peek();
+ if (nx?.t === "pctop" && nx.op === "of") this.i++;
+ const arg = this.parseUnary();
+ return arg ? { k: "call", name, args: [arg] } : null;
+ }
+ case "lparen": {
+ this.i++;
+ const inner = this.parseConv();
+ if (this.peek()?.t === "rparen") this.i++;
+ return inner;
+ }
+ case "currency": {
+ // prefix currency: $9
+ const nxt = this.toks[this.i + 1];
+ if (nxt?.t === "num") {
+ this.i += 2;
+ return { k: "curr", x: { k: "num", v: nxt.v, repr: nxt.repr }, code: tk.code };
+ }
+ return null;
+ }
+ default:
+ return null;
+ }
+ }
+}
diff --git a/src/engine/rates-snapshot.json b/src/engine/rates-snapshot.json
new file mode 100644
index 0000000..7fec6f6
--- /dev/null
+++ b/src/engine/rates-snapshot.json
@@ -0,0 +1,33 @@
+{
+ "date": "2026-06-01",
+ "base": "USD",
+ "rates": {
+ "USD": 1, "EUR": 0.92, "GBP": 0.78, "JPY": 155, "CNY": 7.2, "CHF": 0.88,
+ "CAD": 1.36, "AUD": 1.5, "NZD": 1.64, "RUB": 92, "UAH": 41, "BYN": 3.27,
+ "KZT": 470, "TRY": 38, "INR": 84, "BRL": 5.4, "MXN": 18.5, "KRW": 1370,
+ "SGD": 1.34, "HKD": 7.8, "TWD": 32, "THB": 35, "VND": 25400, "IDR": 16200,
+ "MYR": 4.6, "PHP": 57, "PLN": 3.95, "CZK": 23, "HUF": 360, "RON": 4.6,
+ "BGN": 1.8, "SEK": 10.5, "NOK": 10.7, "DKK": 6.9, "ISK": 138, "ILS": 3.7,
+ "AED": 3.6725, "SAR": 3.75, "QAR": 3.64, "KWD": 0.31, "BHD": 0.376,
+ "OMR": 0.3845, "JOD": 0.709, "EGP": 48, "ZAR": 18.2, "NGN": 1550,
+ "KES": 130, "GHS": 15.5, "MAD": 9.9, "DZD": 134, "TND": 3.1, "BTC": 0.0000095,
+ "AFN": 71, "ALL": 92, "AMD": 388, "ANG": 1.79, "AOA": 900, "ARS": 1050,
+ "AWG": 1.79, "AZN": 1.7, "BAM": 1.8, "BBD": 2, "BDT": 118, "BIF": 2900,
+ "BMD": 1, "BND": 1.34, "BOB": 6.9, "BSD": 1, "BTN": 84, "BWP": 13.5,
+ "BYR": 32700, "BZD": 2, "CDF": 2800, "CLP": 940, "COP": 4100, "CRC": 515,
+ "CUP": 24, "CVE": 101, "DJF": 178, "DOP": 60, "ERN": 15, "ETB": 120,
+ "FJD": 2.25, "FKP": 0.78, "GEL": 2.75, "GIP": 0.78, "GMD": 70, "GNF": 8600,
+ "GTQ": 7.75, "GYD": 209, "HNL": 25, "HRK": 6.93, "HTG": 132, "IQD": 1310,
+ "IRR": 42000, "JMD": 156, "KGS": 87, "KHR": 4050, "KMF": 452, "KPW": 900,
+ "KYD": 0.83, "LAK": 21500, "LBP": 89500, "LKR": 300, "LRD": 192, "LSL": 18.2,
+ "LTL": 3.18, "LVL": 0.65, "LYD": 4.85, "MDL": 17.7, "MGA": 4550, "MKD": 56.5,
+ "MMK": 2100, "MNT": 3400, "MOP": 8.03, "MRO": 357, "MUR": 46, "MVR": 15.4,
+ "MWK": 1730, "MZN": 64, "NAD": 18.2, "NIO": 36.8, "NPR": 134, "PAB": 1,
+ "PEN": 3.75, "PGK": 3.9, "PKR": 278, "PYG": 7600, "RSD": 107.5, "RWF": 1350,
+ "SBD": 8.4, "SCR": 14, "SDG": 600, "SHP": 0.78, "SLL": 22500, "SOS": 571,
+ "SRD": 33, "SSP": 1800, "STD": 22600, "SVC": 8.75, "SYP": 13000, "SZL": 18.2,
+ "TJS": 10.6, "TMT": 3.5, "TOP": 2.36, "TTD": 6.8, "TZS": 2650, "UGX": 3700,
+ "UYU": 41, "UZS": 12700, "VEF": 3600000, "VUV": 119, "WST": 2.75,
+ "XAF": 603, "XCD": 2.7, "XOF": 603, "XPF": 110, "YER": 250, "ZMW": 26
+ }
+}
diff --git a/src/engine/registry.ts b/src/engine/registry.ts
new file mode 100644
index 0000000..09b6717
--- /dev/null
+++ b/src/engine/registry.ts
@@ -0,0 +1,394 @@
+// Phrase registry: maps every phrase the engine understands (units,
+// currencies, word-operators, functions, scales, date words) to a payload
+// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
+// like "m"/"M", "kB") are tried before the case-insensitive ones.
+import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types";
+import { lexPhrase } from "./lexer";
+import * as vocab from "./vocab";
+import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
+import { EXTRA_UNITS, CRYPTO } from "./extraunits";
+
+export type PctOp =
+ | "of" | "off" | "on"
+ | "as_pct_of" | "as_pct_off" | "as_pct_on"
+ | "of_what_is" | "off_what_is" | "on_what_is";
+
+export type DateWord = "today" | "tomorrow" | "yesterday" | "now" | "time" | "local";
+
+export type BitOp = "band" | "bor" | "bxor" | "shl" | "shr";
+
+export type Payload =
+ | { t: "unit"; unit: Unit }
+ | { t: "currency"; code: string }
+ | { t: "op"; op: "plus" | "minus" | "mul" | "div" | "mod" }
+ | { t: "bitop"; op: BitOp }
+ | { t: "conv" }
+ | { t: "assign" }
+ | { t: "pctop"; op: PctOp }
+ | { t: "percent" }
+ | { t: "func"; name: string }
+ | { t: "agg"; name: "sum" | "avg" | "prev" }
+ | { t: "scale"; mult: Decimal }
+ | { t: "repr"; repr: NumeralRepr }
+ | { t: "special"; name: "unix" | "todate" }
+ | { t: "date"; word: DateWord }
+ | { t: "const"; name: "pi" | "e" };
+
+interface PhraseEntry {
+ lexemes: string[]; // raw, as in the phrase
+ lower: string[];
+ caseSensitive: boolean;
+ payload: Payload;
+}
+
+export type ExtensionFunc = (args: Value[]) => Value;
+
+export interface Completion {
+ label: string;
+ type: "unit" | "currency" | "function" | "keyword" | "variable";
+ detail?: string;
+}
+
+const BUILTIN_FUNCS = [
+ "sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
+ "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
+ "ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
+ "fact", "factorial",
+];
+
+export class Registry {
+ /** key: lowercased first lexeme */
+ private byFirst = new Map();
+ unitsById = new Map();
+ currencyCodes = new Set();
+ currencyFormat = new Map();
+ /** code -> currency units per 1 USD */
+ rates = new Map();
+ customFuncs = new Map();
+ completions: Completion[] = [];
+
+ addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
+ const lexemes = lexPhrase(phrase);
+ if (lexemes.length === 0) return;
+ const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2);
+ const entry: PhraseEntry = { lexemes, lower: lexemes.map((l) => l.toLowerCase()), caseSensitive, payload };
+ const key = entry.lower[0];
+ const list = this.byFirst.get(key);
+ if (list) {
+ // first registration of an identical phrase wins (core vocab is added first)
+ if (list.some((e) => e.caseSensitive === caseSensitive && (caseSensitive ? e.lexemes.join(" ") === lexemes.join(" ") : e.lower.join(" ") === entry.lower.join(" ")))) {
+ return;
+ }
+ list.push(entry);
+ list.sort((a, b) => b.lexemes.length - a.lexemes.length || Number(b.caseSensitive) - Number(a.caseSensitive));
+ } else {
+ this.byFirst.set(key, [entry]);
+ }
+ }
+
+ /**
+ * Match the longest phrase at position i of the lexeme stream.
+ * rawAt/lowerAt give the lexemes of the input line.
+ */
+ match(raws: string[], lowers: string[], i: number): { payload: Payload; length: number } | null {
+ const list = this.byFirst.get(lowers[i]);
+ if (!list) return null;
+ for (const e of list) {
+ if (i + e.lexemes.length > raws.length) continue;
+ let ok = true;
+ for (let k = 0; k < e.lexemes.length; k++) {
+ if (e.caseSensitive ? raws[i + k] !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) return { payload: e.payload, length: e.lexemes.length };
+ }
+ return null;
+ }
+
+ setRates(rates: Record): void {
+ this.rates.clear();
+ for (const [code, v] of Object.entries(rates)) {
+ const d = new Decimal(v);
+ if (d.isFinite() && d.gt(0)) this.rates.set(code.toUpperCase(), d);
+ }
+ this.rates.set("USD", new Decimal(1));
+ }
+
+ makeCurrencyUnit(code: string): Unit | null {
+ const rate = this.rates.get(code);
+ if (!rate) return null;
+ return {
+ id: code,
+ dimension: "currency",
+ ratio: new Decimal(1).div(rate), // USD per 1 unit
+ format: this.currencyFormat.get(code) ?? `{} ${code}`,
+ };
+ }
+
+ isFunc(name: string): boolean {
+ return BUILTIN_FUNCS.includes(name) || this.customFuncs.has(name);
+ }
+}
+
+function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { caseSensitive?: boolean } = {}): void {
+ reg.unitsById.set(unit.id, unit);
+ for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts);
+}
+
+export function buildRegistry(): Registry {
+ const reg = new Registry();
+
+ // operators, percent, dates, numerals, scales, aggregates
+ const ops: Array<[string, Payload]> = [
+ ["plus", { t: "op", op: "plus" }],
+ ["minus", { t: "op", op: "minus" }],
+ ["multiply", { t: "op", op: "mul" }],
+ ["divide", { t: "op", op: "div" }],
+ ];
+ for (const [key, payload] of ops) {
+ for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
+ }
+ for (const v of vocab.variants("Operations", "conversion.variants")) reg.addPhrase(v, { t: "conv" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "assignment.variants")) reg.addPhrase(v, { t: "assign" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
+ for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
+ reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
+ reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
+
+ // bitwise: "and" stays an addition word (original Numi), so & | xor shl shr
+ reg.addPhrase("xor", { t: "bitop", op: "bxor" }, { caseSensitive: false });
+ reg.addPhrase("or", { t: "bitop", op: "bor" }, { caseSensitive: false });
+ reg.addPhrase("shl", { t: "bitop", op: "shl" }, { caseSensitive: false });
+ reg.addPhrase("shr", { t: "bitop", op: "shr" }, { caseSensitive: false });
+
+ // unix timestamps and extra numeral representations
+ for (const v of ["unix", "unixtime", "unix time", "timestamp", "таймстамп"]) {
+ reg.addPhrase(v, { t: "special", name: "unix" }, { caseSensitive: false });
+ }
+ for (const v of ["date", "datetime", "дата", "дату", "датой"]) {
+ reg.addPhrase(v, { t: "special", name: "todate" }, { caseSensitive: false });
+ }
+ for (const v of ["fraction", "fractions", "дробь", "дробью", "дроби"]) {
+ reg.addPhrase(v, { t: "repr", repr: "fraction" }, { caseSensitive: false });
+ }
+ for (const v of ["roman", "roman numerals", "римские", "римскими"]) {
+ reg.addPhrase(v, { t: "repr", repr: "roman" }, { caseSensitive: false });
+ }
+
+ for (const v of vocab.variants("Percent", "percent.variants")) reg.addPhrase(v, { t: "percent" });
+ const pctOps: Array<[string, PctOp]> = [
+ ["percent_of", "of"], ["percent_off", "off"], ["percent_on", "on"],
+ ["as_a_percent_of", "as_pct_of"], ["as_a_percent_off", "as_pct_off"], ["as_a_percent_on", "as_pct_on"],
+ ["percent_of_what_is", "of_what_is"], ["percent_off_what_is", "off_what_is"], ["percent_on_what_is", "on_what_is"],
+ ];
+ for (const [key, op] of pctOps) {
+ for (const v of vocab.variants("Percent", `${key}.variants`)) reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false });
+ }
+
+ const dateWords: Array<[string, DateWord]> = [
+ ["today", "today"], ["tomorrow", "tomorrow"], ["yesterday", "yesterday"],
+ ];
+ for (const [key, word] of dateWords) {
+ for (const v of vocab.variants("Dates", `${key}.variants`)) reg.addPhrase(v, { t: "date", word }, { caseSensitive: false });
+ }
+ for (const v of vocab.variants("Dates", "current_time.variants")) reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false });
+ for (const v of vocab.variants("Dates", "local_time.variants")) reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false });
+
+ const reprs: Array<[string, NumeralRepr]> = [
+ ["hex", "hex"], ["binary", "binary"], ["octal", "octal"], ["decimal", "decimal"], ["scientific", "scientific"],
+ ];
+ for (const [key, repr] of reprs) {
+ for (const v of vocab.variants("Numbers", `${key}.variants`)) reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false });
+ }
+
+ for (const [id, mult] of Object.entries(SCALE_DATA)) {
+ for (const v of vocab.variants("Scales", `${id}.variants`)) {
+ reg.addPhrase(v, { t: "scale", mult: new Decimal(mult) });
+ }
+ }
+
+ reg.addPhrase("pi", { t: "const", name: "pi" }, { caseSensitive: false });
+ reg.addPhrase("π", { t: "const", name: "pi" });
+ reg.addPhrase("пи", { t: "const", name: "pi" }, { caseSensitive: false });
+ reg.addPhrase("e", { t: "const", name: "e" }, { caseSensitive: true });
+
+ for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
+
+ // units, SI/IEC prefixes, area/volume templates
+ interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
+ const lengthPhrases: LengthPhrase[] = [];
+
+ for (const d of UNIT_DATA) {
+ const variants = vocab.variants(d.category, `${d.id}.variants`);
+ const format = vocab.entryEn(d.category, `${d.id}.format`) ?? d.format ?? d.id;
+ const symbol = vocab.entryEn(d.category, `${d.id}.symbol`);
+ const symbolsAll = vocab.entriesAll(d.category, `${d.id}.symbol`);
+ const unit: Unit = {
+ id: d.id,
+ dimension: d.dimension,
+ ratio: new Decimal(d.ratio),
+ ...(d.offset ? { offset: new Decimal(d.offset) } : {}),
+ format,
+ };
+ addUnitPhrases(reg, variants, unit);
+ if (d.dimension === "length") {
+ for (const v of variants) lengthPhrases.push({ phrase: v, unit });
+ }
+
+ if (d.prefixes) {
+ let prefixes = d.prefixes === "si" ? SI_PREFIXES : [...DATA_SI_PREFIXES, ...IEC_PREFIXES];
+ if (d.id === "second") {
+ // only sub-second prefixes are useful; "as" (attosecond) would
+ // shadow the conversion word "as", "das"/"hs" are noise
+ prefixes = prefixes.filter((p) => ["milli", "micro", "nano", "pico"].includes(p.id));
+ }
+ const wordVariants = variants.filter((v) => v.length > 2 && /^[\p{L}]+$/u.test(v));
+ for (const p of prefixes) {
+ const pWords = vocab.variants(p.category, `${p.id}.prefix`);
+ const pSym = vocab.entryEn(p.category, `${p.id}.symbol`) ?? "";
+ const pSymsAll = vocab.entriesAll(p.category, `${p.id}.symbol`);
+ const mult = new Decimal(p.mult);
+ const pUnit: Unit = {
+ id: `${p.id}:${d.id}`,
+ dimension: d.dimension,
+ ratio: unit.ratio.mul(mult),
+ format: pSym + (symbol ?? format),
+ };
+ const phrases: string[] = [];
+ const csPhrases: string[] = [];
+ if (d.id === "bit") {
+ // no "kb" for bits — bare *b symbols mean bytes (common expectation)
+ csPhrases.push(`${pSym}bit`);
+ } else {
+ // symbol compositions in every locale: "km" and "км"
+ for (const ps of pSymsAll) for (const us of symbolsAll) csPhrases.push(ps + us);
+ }
+ for (const pw of pWords) for (const wv of wordVariants) phrases.push(pw + wv);
+ if (d.id === "byte" && pSym) {
+ // lenient: KB/kb/Kb all mean kilobyte
+ phrases.push((pSym + "b").toLowerCase());
+ }
+ reg.unitsById.set(pUnit.id, pUnit);
+ for (const ph of csPhrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });
+ for (const ph of phrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false });
+ if (d.dimension === "length") {
+ for (const ph of csPhrases) lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });
+ for (const pw of pWords) for (const wv of wordVariants) lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false });
+ }
+ }
+ }
+ }
+
+ // Area/volume from templates: "square %@" / "cubic %@" applied to every length phrase
+ const applyTemplates = (
+ templates: string[],
+ fmtTemplate: string,
+ dim: Dimension,
+ pow: 2 | 3,
+ idPrefix: string,
+ ) => {
+ for (const t of templates) {
+ for (const lp of lengthPhrases) {
+ const phrase = t.replace("%@", lp.phrase);
+ const id = `${idPrefix}:${lp.unit.id}`;
+ let u = reg.unitsById.get(id);
+ if (!u) {
+ u = {
+ id,
+ dimension: dim,
+ ratio: lp.unit.ratio.pow(pow),
+ format: fmtTemplate.replace("%@", lp.unit.format),
+ };
+ reg.unitsById.set(id, u);
+ }
+ reg.addPhrase(phrase, { t: "unit", unit: u }, lp.caseSensitive !== undefined ? { caseSensitive: lp.caseSensitive } : {});
+ }
+ }
+ };
+ applyTemplates(vocab.variants("Area", "area.templates"), vocab.entryEn("Area", "area.format") ?? "%@²", "area", 2, "sq");
+ applyTemplates(vocab.variants("Volume", "volume.templates"), vocab.entryEn("Volume", "volume.format") ?? "%@³", "volume", 3, "cb");
+
+ // currencies
+ // Major currencies register first so they win generic phrases
+ // ("доллар" belongs to USD, not TTD which lists it too and sorts earlier).
+ const PRIORITY = [
+ "USD", "EUR", "GBP", "RUB", "JPY", "CNY", "CHF", "CAD", "AUD", "UAH",
+ "BYN", "KZT", "PLN", "TRY", "INR", "BRL", "KRW", "NZD", "SEK", "NOK",
+ "DKK", "CZK", "ILS", "HKD", "SGD", "BTC",
+ ];
+ const codes = vocab.idsOf("Currency").sort((a, b) => {
+ const pa = PRIORITY.indexOf(a);
+ const pb = PRIORITY.indexOf(b);
+ return (pa === -1 ? 999 : pa) - (pb === -1 ? 999 : pb) || a.localeCompare(b);
+ });
+ for (const code of codes) {
+ reg.currencyCodes.add(code);
+ const fmt = vocab.entryEn("Currency", `${code}.format`);
+ if (fmt) reg.currencyFormat.set(code, fmt.replace("%@", "{}"));
+ const variants = vocab.variants("Currency", `${code}.variants`);
+ if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code);
+ for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });
+ }
+
+ // speed, pressure, energy, power, frequency, fuel
+ for (const d of EXTRA_UNITS) {
+ const unit: Unit = {
+ id: d.id,
+ dimension: d.dimension,
+ ratio: new Decimal(d.ratio),
+ ...(d.reciprocal ? { reciprocal: true } : {}),
+ format: d.format,
+ };
+ reg.unitsById.set(unit.id, unit);
+ for (const p of d.phrases.split(",")) {
+ const ph = p.trim();
+ if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: false });
+ }
+ if (d.symbols) {
+ for (const s of d.symbols.split(",")) {
+ const ph = s.trim();
+ if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: true });
+ }
+ }
+ }
+
+ // cryptocurrencies (live prices come from the shell)
+ // codes that collide with common words match case-sensitively only
+ const CRYPTO_STRICT_CODES = new Set(["TON", "NEAR", "ATOM", "LINK", "UNI", "DOT", "SOL"]);
+ for (const c of CRYPTO) {
+ reg.currencyCodes.add(c.code);
+ reg.currencyFormat.set(c.code, `{} ${c.code}`);
+ reg.addPhrase(c.code, { t: "currency", code: c.code }, { caseSensitive: CRYPTO_STRICT_CODES.has(c.code) });
+ for (const p of c.phrases.split(",")) {
+ const ph = p.trim();
+ if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });
+ }
+ }
+
+ // autocomplete entries
+ const seen = new Set();
+ const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
+ const k = `${type}:${label.toLowerCase()}`;
+ if (seen.has(k) || label.length < 2 || !/^[\p{L}]/u.test(label)) return;
+ seen.add(k);
+ reg.completions.push({ label, type, detail });
+ };
+ for (const d of UNIT_DATA) {
+ for (const v of vocab.variants(d.category, `${d.id}.variants`)) addCompletion(v, "unit", d.dimension);
+ }
+ for (const d of EXTRA_UNITS) {
+ for (const v of d.phrases.split(",")) addCompletion(v.trim(), "unit", d.dimension);
+ }
+ for (const code of reg.currencyCodes) addCompletion(code, "currency");
+ for (const f of BUILTIN_FUNCS) addCompletion(f, "function");
+ for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
+ addCompletion(k, "keyword");
+ }
+
+ return reg;
+}
diff --git a/src/engine/tokenizer.ts b/src/engine/tokenizer.ts
new file mode 100644
index 0000000..be182f8
--- /dev/null
+++ b/src/engine/tokenizer.ts
@@ -0,0 +1,128 @@
+// Lexes a line and resolves phrases through the registry into semantic tokens.
+import { Decimal, NumeralRepr, Unit } from "./types";
+import { lexLine } from "./lexer";
+import { Registry, PctOp, DateWord, BitOp } from "./registry";
+
+export type Token =
+ | { t: "num"; v: Decimal; repr: NumeralRepr; start: number; end: number }
+ | { t: "unit"; unit: Unit; start: number; end: number }
+ | { t: "currency"; code: string; start: number; end: number }
+ | { t: "op"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; start: number; end: number }
+ | { t: "bitop"; op: BitOp; start: number; end: number }
+ | { t: "special"; name: "unix" | "todate"; start: number; end: number }
+ | { t: "conv"; start: number; end: number }
+ | { t: "assign"; start: number; end: number }
+ | { t: "pctop"; op: PctOp; start: number; end: number }
+ | { t: "percent"; start: number; end: number }
+ | { t: "func"; name: string; start: number; end: number }
+ | { t: "agg"; name: "sum" | "avg" | "prev"; start: number; end: number }
+ | { t: "scale"; mult: Decimal; start: number; end: number }
+ | { t: "repr"; repr: NumeralRepr; start: number; end: number }
+ | { t: "date"; word: DateWord; start: number; end: number }
+ | { t: "const"; name: "pi" | "e"; start: number; end: number }
+ | { t: "lparen"; start: number; end: number }
+ | { t: "rparen"; start: number; end: number }
+ | { t: "semicolon"; start: number; end: number }
+ | { t: "bang"; start: number; end: number }
+ | { t: "word"; raw: string; start: number; end: number }
+ | { t: "junk"; raw: string; start: number; end: number };
+
+const SYM_OPS: Record = {};
+
+export function tokenize(line: string, reg: Registry): Token[] {
+ void SYM_OPS;
+ const lexes = lexLine(line);
+ const raws = lexes.map((l) => l.raw);
+ const lowers = raws.map((r) => r.toLowerCase());
+ const tokens: Token[] = [];
+ let i = 0;
+ while (i < lexes.length) {
+ const lx = lexes[i];
+ const span = { start: lx.start, end: lx.end };
+
+ if (lx.type === "num") {
+ tokens.push({ t: "num", v: lx.value!, repr: lx.repr!, ...span });
+ i++;
+ continue;
+ }
+
+ const m = reg.match(raws, lowers, i);
+ if (m) {
+ const end = lexes[i + m.length - 1].end;
+ const s = { start: lx.start, end };
+ const p = m.payload;
+ switch (p.t) {
+ case "unit": tokens.push({ t: "unit", unit: p.unit, ...s }); break;
+ case "currency": tokens.push({ t: "currency", code: p.code, ...s }); break;
+ case "op": tokens.push({ t: "op", op: p.op, ...s }); break;
+ case "bitop": tokens.push({ t: "bitop", op: p.op, ...s }); break;
+ case "special": tokens.push({ t: "special", name: p.name, ...s }); break;
+ case "conv": tokens.push({ t: "conv", ...s }); break;
+ case "assign": tokens.push({ t: "assign", ...s }); break;
+ case "pctop": tokens.push({ t: "pctop", op: p.op, ...s }); break;
+ case "percent": tokens.push({ t: "percent", ...s }); break;
+ case "func": tokens.push({ t: "func", name: p.name, ...s }); break;
+ case "agg": tokens.push({ t: "agg", name: p.name, ...s }); break;
+ case "scale": tokens.push({ t: "scale", mult: p.mult, ...s }); break;
+ case "repr": tokens.push({ t: "repr", repr: p.repr, ...s }); break;
+ case "date": tokens.push({ t: "date", word: p.word, ...s }); break;
+ case "const": tokens.push({ t: "const", name: p.name, ...s }); break;
+ }
+ i += m.length;
+ continue;
+ }
+
+ if (lx.type === "sym") {
+ // << and >> arrive as two single-char lexemes
+ const nextLx = lexes[i + 1];
+ if ((lx.raw === "<" || lx.raw === ">") && nextLx?.type === "sym" && nextLx.raw === lx.raw && nextLx.start === lx.end) {
+ tokens.push({ t: "bitop", op: lx.raw === "<" ? "shl" : "shr", start: lx.start, end: nextLx.end });
+ i += 2;
+ continue;
+ }
+ switch (lx.raw) {
+ case "&": tokens.push({ t: "bitop", op: "band", ...span }); break;
+ case "|": tokens.push({ t: "bitop", op: "bor", ...span }); break;
+ case "+": tokens.push({ t: "op", op: "plus", ...span }); break;
+ case "-": case "−": case "–": tokens.push({ t: "op", op: "minus", ...span }); break;
+ case "*": case "×": case "·": tokens.push({ t: "op", op: "mul", ...span }); break;
+ case "/": case "÷": tokens.push({ t: "op", op: "div", ...span }); break;
+ case "^": tokens.push({ t: "op", op: "pow", ...span }); break;
+ case "%": tokens.push({ t: "percent", ...span }); break;
+ case "(": tokens.push({ t: "lparen", ...span }); break;
+ case ")": tokens.push({ t: "rparen", ...span }); break;
+ case "=": tokens.push({ t: "assign", ...span }); break;
+ case ";": tokens.push({ t: "semicolon", ...span }); break;
+ case "!": tokens.push({ t: "bang", ...span }); break;
+ default: tokens.push({ t: "junk", raw: lx.raw, ...span });
+ }
+ i++;
+ continue;
+ }
+
+ tokens.push({ t: "word", raw: lx.raw, ...span });
+ i++;
+ }
+ return disambiguateIn(tokens, reg, line);
+}
+
+/**
+ * "5 ft 4 in in cm" / "6 ft 3 in": the word "in" right after a number,
+ * followed by another conversion word, an operator or the end of the line,
+ * actually means inches.
+ */
+function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
+ const inch = reg.unitsById.get("inch");
+ if (!inch) return tokens;
+ return tokens.map((tk, i) => {
+ if (tk.t !== "conv" || line.slice(tk.start, tk.end).toLowerCase() !== "in") return tk;
+ const prev = tokens[i - 1];
+ const next = tokens[i + 1];
+ const prevIsNum = prev?.t === "num";
+ const nextEnds = !next || next.t === "conv" || next.t === "op" || next.t === "rparen";
+ if (prevIsNum && nextEnds) {
+ return { t: "unit", unit: inch, start: tk.start, end: tk.end } as Token;
+ }
+ return tk;
+ });
+}
diff --git a/src/engine/types.ts b/src/engine/types.ts
new file mode 100644
index 0000000..5527756
--- /dev/null
+++ b/src/engine/types.ts
@@ -0,0 +1,90 @@
+import Decimal from "decimal.js";
+
+Decimal.set({ precision: 40, toExpNeg: -9, toExpPos: 21 });
+
+export { Decimal };
+
+export type Dimension =
+ | "scalar"
+ | "length"
+ | "area"
+ | "volume"
+ | "weight"
+ | "temperature"
+ | "time"
+ | "angle"
+ | "data"
+ | "currency"
+ | "speed"
+ | "pressure"
+ | "energy"
+ | "power"
+ | "frequency"
+ | "fuel";
+
+export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
+
+/** A resolved unit instance attached to a value. */
+export interface Unit {
+ /** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
+ id: string;
+ dimension: Dimension;
+ /** multiplier to the dimension's base unit (meter, m², m³, gram, second, radian, bit, K, USD) */
+ ratio: Decimal;
+ /** additive offset for temperature: base = value*ratio + offset */
+ offset?: Decimal;
+ /** reciprocal unit (mpg vs L/100km): base = ratio / value */
+ reciprocal?: boolean;
+ /** display format: either a template with {} or a suffix, e.g. "m", "″", "${}" */
+ format: string;
+}
+
+export interface Quantity {
+ kind: "quantity";
+ value: Decimal;
+ unit: Unit | null;
+ repr: NumeralRepr;
+}
+
+export interface PercentVal {
+ kind: "percent";
+ value: Decimal; // 20% -> 20
+}
+
+export interface DateVal {
+ kind: "date";
+ /** epoch ms */
+ ms: number;
+ hasTime: boolean;
+ /** show only the time part ("time in Tokyo") */
+ timeOnly?: boolean;
+ /** IANA timezone for display, defaults to local */
+ timeZone?: string;
+}
+
+export type Value = Quantity | PercentVal | DateVal;
+
+export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity {
+ return { kind: "quantity", value: new Decimal(value), unit, repr };
+}
+
+export function pct(value: Decimal.Value): PercentVal {
+ return { kind: "percent", value: new Decimal(value) };
+}
+
+export class EvalError extends Error {}
+
+export interface EngineSettings {
+ /** max decimal places shown */
+ precision: number;
+ /** thousands separator: "," | " " | "" */
+ groupSeparator: string;
+ /** decimal separator shown in output */
+ decimalSeparator: string;
+}
+
+export const defaultSettings: EngineSettings = {
+ precision: 2,
+ groupSeparator: ",",
+ decimalSeparator: ".",
+};
diff --git a/src/engine/unitdata.ts b/src/engine/unitdata.ts
new file mode 100644
index 0000000..92a1c7f
--- /dev/null
+++ b/src/engine/unitdata.ts
@@ -0,0 +1,135 @@
+// Conversion ratios. Base units per dimension: length=meter, area=m²,
+// volume=m³, weight=gram, time=second, angle=radian, data=bit,
+// temperature=kelvin (ratio+offset).
+import { Decimal, Dimension } from "./types";
+
+export interface UnitData {
+ id: string;
+ /** vocab category that holds `.variants` / `.format` */
+ category: string;
+ dimension: Dimension;
+ ratio: string;
+ offset?: string;
+ /** which prefix family applies (si => mm/cm/km..., data => kilo..yotta + kibi..yobi) */
+ prefixes?: "si" | "data";
+ /** fallback format if vocab has none */
+ format?: string;
+}
+
+const IN = 0.0254;
+
+export const UNIT_DATA: UnitData[] = [
+ // Length (base: meter)
+ { id: "meter", category: "Length", dimension: "length", ratio: "1", prefixes: "si" },
+ { id: "inch", category: "Length", dimension: "length", ratio: String(IN) },
+ { id: "mile", category: "Length", dimension: "length", ratio: "1609.344" },
+ { id: "foot", category: "Length", dimension: "length", ratio: "0.3048" },
+ { id: "yard", category: "Length", dimension: "length", ratio: "0.9144" },
+ { id: "point", category: "Length", dimension: "length", ratio: String(IN / 72) },
+ { id: "cable", category: "Length", dimension: "length", ratio: "185.2" },
+ { id: "league", category: "Length", dimension: "length", ratio: "4828.032" },
+ { id: "furlong", category: "Length", dimension: "length", ratio: "201.168" },
+ { id: "nautical_mile", category: "Length", dimension: "length", ratio: "1852" },
+ { id: "chain", category: "Length", dimension: "length", ratio: "20.1168" },
+ // CSS lengths (reference: 96px = 1in, 1em = 16px)
+ { id: "pixel", category: "Css", dimension: "length", ratio: String(IN / 96) },
+ { id: "em", category: "Css", dimension: "length", ratio: String(IN / 6) },
+
+ // Area (base: m²)
+ { id: "are", category: "Area", dimension: "area", ratio: "100" },
+ { id: "hectare", category: "Area", dimension: "area", ratio: "10000" },
+ { id: "acre", category: "Area", dimension: "area", ratio: "4046.8564224" },
+ { id: "ping", category: "Area", dimension: "area", ratio: "3.30579" },
+
+ // Volume (base: m³)
+ { id: "litre", category: "Volume", dimension: "volume", ratio: "0.001", prefixes: "si" },
+ { id: "cup", category: "Volume", dimension: "volume", ratio: "0.0002365882365" },
+ { id: "tablespoon", category: "Volume", dimension: "volume", ratio: "0.0000147867647825" },
+ { id: "teaspoon", category: "Volume", dimension: "volume", ratio: "0.00000492892159375" },
+ { id: "quart", category: "Volume", dimension: "volume", ratio: "0.000946352946" },
+ { id: "pint", category: "Volume", dimension: "volume", ratio: "0.000473176473" },
+ { id: "gallon", category: "Volume", dimension: "volume", ratio: "0.003785411784" },
+ { id: "cbinch", category: "Volume", dimension: "volume", ratio: "0.000016387064", format: "in³" },
+ { id: "cbcm", category: "Volume", dimension: "volume", ratio: "0.000001", format: "cm³" },
+
+ // Weight (base: gram)
+ { id: "gram", category: "Weight", dimension: "weight", ratio: "1", prefixes: "si" },
+ { id: "tonne", category: "Weight", dimension: "weight", ratio: "1000000" },
+ { id: "stone", category: "Weight", dimension: "weight", ratio: "6350.29318" },
+ { id: "carat", category: "Weight", dimension: "weight", ratio: "0.2" },
+ { id: "pound", category: "Weight", dimension: "weight", ratio: "453.59237" },
+ { id: "centner", category: "Weight", dimension: "weight", ratio: "100000" },
+ { id: "ounce", category: "Weight", dimension: "weight", ratio: "28.349523125" },
+
+ // Temperature (base: kelvin); base = value*ratio + offset
+ { id: "celsius", category: "Temperature", dimension: "temperature", ratio: "1", offset: "273.15" },
+ { id: "fahrenheit", category: "Temperature", dimension: "temperature", ratio: String(5 / 9), offset: String((459.67 * 5) / 9) },
+ { id: "kelvin", category: "Temperature", dimension: "temperature", ratio: "1" },
+
+ // Time (base: second). Month = 30 days, year = 365 days here;
+ // date arithmetic uses real calendar months separately.
+ { id: "second", category: "Time", dimension: "time", ratio: "1", prefixes: "si" },
+ { id: "minute", category: "Time", dimension: "time", ratio: "60" },
+ { id: "hour", category: "Time", dimension: "time", ratio: "3600" },
+ { id: "day", category: "Time", dimension: "time", ratio: "86400" },
+ { id: "week", category: "Time", dimension: "time", ratio: "604800" },
+ { id: "month", category: "Time", dimension: "time", ratio: "2592000" },
+ { id: "year", category: "Time", dimension: "time", ratio: "31536000" },
+
+ // Angle (base: radian)
+ { id: "radian", category: "Angle", dimension: "angle", ratio: "1" },
+ { id: "degree", category: "Angle", dimension: "angle", ratio: String(Math.PI / 180) },
+
+ // Data (base: bit)
+ { id: "bit", category: "Data", dimension: "data", ratio: "1", prefixes: "data" },
+ { id: "byte", category: "Data", dimension: "data", ratio: "8", prefixes: "data" },
+];
+
+export interface PrefixData {
+ id: string; // vocab id in SIPrefixes/IECPrefixes
+ category: "SIPrefixes" | "IECPrefixes";
+ mult: string;
+}
+
+export const SI_PREFIXES: PrefixData[] = [
+ { id: "yotta", category: "SIPrefixes", mult: "1e24" },
+ { id: "zetta", category: "SIPrefixes", mult: "1e21" },
+ { id: "exa", category: "SIPrefixes", mult: "1e18" },
+ { id: "peta", category: "SIPrefixes", mult: "1e15" },
+ { id: "tera", category: "SIPrefixes", mult: "1e12" },
+ { id: "giga", category: "SIPrefixes", mult: "1e9" },
+ { id: "mega", category: "SIPrefixes", mult: "1e6" },
+ { id: "kilo", category: "SIPrefixes", mult: "1e3" },
+ { id: "hecto", category: "SIPrefixes", mult: "1e2" },
+ { id: "deca", category: "SIPrefixes", mult: "1e1" },
+ { id: "deci", category: "SIPrefixes", mult: "1e-1" },
+ { id: "centi", category: "SIPrefixes", mult: "1e-2" },
+ { id: "milli", category: "SIPrefixes", mult: "1e-3" },
+ { id: "micro", category: "SIPrefixes", mult: "1e-6" },
+ { id: "nano", category: "SIPrefixes", mult: "1e-9" },
+ { id: "pico", category: "SIPrefixes", mult: "1e-12" },
+ { id: "femto", category: "SIPrefixes", mult: "1e-15" },
+ { id: "atto", category: "SIPrefixes", mult: "1e-18" },
+ { id: "zepto", category: "SIPrefixes", mult: "1e-21" },
+ { id: "yocto", category: "SIPrefixes", mult: "1e-24" },
+];
+
+/** Prefixes ≥ kilo, applied to data units (no millibits). */
+export const DATA_SI_PREFIXES = SI_PREFIXES.filter((p) => new Decimal(p.mult).gte(1000));
+
+export const IEC_PREFIXES: PrefixData[] = [
+ { id: "kibi", category: "IECPrefixes", mult: "1024" },
+ { id: "mebi", category: "IECPrefixes", mult: String(2 ** 20) },
+ { id: "gibi", category: "IECPrefixes", mult: String(2 ** 30) },
+ { id: "tebi", category: "IECPrefixes", mult: String(2 ** 40) },
+ { id: "pebi", category: "IECPrefixes", mult: new Decimal(2).pow(50).toString() },
+ { id: "exbi", category: "IECPrefixes", mult: new Decimal(2).pow(60).toString() },
+ { id: "zebi", category: "IECPrefixes", mult: new Decimal(2).pow(70).toString() },
+ { id: "yobi", category: "IECPrefixes", mult: new Decimal(2).pow(80).toString() },
+];
+
+export const SCALE_DATA: Record = {
+ thousand: "1e3",
+ million: "1e6",
+ billion: "1e9",
+};
diff --git a/src/engine/vocab-data.ts b/src/engine/vocab-data.ts
new file mode 100644
index 0000000..d3727cd
--- /dev/null
+++ b/src/engine/vocab-data.ts
@@ -0,0 +1,1015 @@
+// Names, spellings and display formats of units, currencies, word-operators
+// and date words (en + ru). Conversion ratios live in unitdata.ts.
+import type { VocabLocale } from "./vocab";
+
+export const EN: VocabLocale = {
+ "Angle": {
+ "degree.format": "°",
+ "degree.variants": "degree,°,degrees,arcdegree",
+ "radian.format": "rad",
+ "radian.variants": "radian,rad,radians"
+ },
+ "Area": {
+ "acre.format": "acre",
+ "acre.variants": "acre,acres",
+ "are.format": "a",
+ "are.variants": "a,are,ares",
+ "area.format": "%@²",
+ "area.templates": "square %@,sq %@,sq%@,%@²,%@ ²,%@ 2",
+ "hectare.format": "ha",
+ "hectare.variants": "hectare,hectares,ha",
+ "ping.format": "píng",
+ "ping.variants": "ping,píng,pings,píngs"
+ },
+ "Css": {
+ "em.format": "em",
+ "em.variants": "em,ems",
+ "pixel.format": "px",
+ "pixel.variants": "pixel,pixels,px,px."
+ },
+ "Currency": {
+ "AED.format": "%@ Dh",
+ "AED.variants": "United Arab Emirates dirham,Dirham,Dirhams,UAE Dirham,UAE Dirhams,Dhs,د.إ",
+ "AFN.format": "%@ Af",
+ "AFN.variants": "Afgani,Afgan Afgani,Afs,؋,Af",
+ "ALL.format": "%@ L",
+ "ALL.variants": "Lek,Albanian Lek,Albanian Leks",
+ "AMD.format": "%@ Դ",
+ "AMD.variants": "Armenian Dram,Dram,Drams,Armenian Drams,Դ",
+ "ANG.format": "ƒ %@",
+ "ANG.variants": "Netherlands Antillean Guilders,Netherlands Antillean Guilder,Guilder,Guilders,NAƒ,NAf,ƒ",
+ "AOA.format": "%@ Kz",
+ "AOA.variants": "Angolan Kwanzas,Angolan Kwanza,Kwanza,Kwanzas,Kz",
+ "ARS.format": "%@ ARS",
+ "ARS.variants": "Argentine Peso,Argentine Pesos",
+ "AUD.format": "$%@ AUD",
+ "AUD.variants": "Australian Dollars,Australian Dollar",
+ "AWG.format": "%@ Afl.",
+ "AWG.variants": "Aruban florin,florin,Afl.",
+ "AZN.format": "%@ man.",
+ "AZN.variants": "Azerbaijani Manats,Azerbaijani Manat,Manat,Manats,man.",
+ "BAM.format": "%@ KM",
+ "BAM.variants": "Bosnia and Herzegovina convertible mark,Bosnia and Herzegovina convertible marks,convertible mark,convertible marks,Maraka",
+ "BBD.format": "%@ BBD",
+ "BBD.variants": "Barbados Dollars,Barbados Dollar",
+ "BDT.format": "%@ Tk",
+ "BDT.variants": "Bangladeshi Takas,Bangladeshi Taka,Takas,Taka,Tk,৳",
+ "BGN.format": "%@ лв.",
+ "BGN.variants": "Bulgarian Levs,Bulgarian Lev,Lev,Leva,Bulgarian Leva,лв.",
+ "BHD.format": "%@ BD",
+ "BHD.variants": "Bahraini Dinars,Bahraini Dinar,.د.ب,BD",
+ "BIF.format": "%@ FBu",
+ "BIF.variants": "Burundi Francs,Burundi Franc,Burundian franc,Burundian francs,FBu",
+ "BMD.format": "$ %@",
+ "BMD.variants": "Bermudian Dollars,Bermudian Dollar,BD$",
+ "BND.format": "B$ %@",
+ "BND.variants": "Brunei Dollars,Brunei Dollar",
+ "BOB.format": "Bs %@",
+ "BOB.variants": "Bolivian Bolivianos,Bolivian Boliviano,Bolivianos,Boliviano,Bs",
+ "BRL.format": "R$ %@",
+ "BRL.variants": "Brazilian Reals,Brazilian Real,R$",
+ "BSD.format": "B$ %@",
+ "BSD.variants": "Bahamian Dollars,Bahamian Dollar",
+ "BTC.format": "%@ B⃦",
+ "BTC.variants": "Bitcoins,Bitcoin,B⃦,xbt",
+ "BTN.format": "Nu. %@",
+ "BTN.variants": "Bhutanese Ngultrum,Ngultrum,Bhutanese Ngultrums,Ngultrums,Nu.",
+ "BWP.format": "%@ BWP",
+ "BWP.variants": "Botswana Pula,Pula",
+ "BYN.format": "%@ BYN",
+ "BYN.variants": "Belarusian ruble,Belarusian rubles,bel. ruble,bel rubles",
+ "BYR.format": "%@ BYR",
+ "BYR.variants": "BYR",
+ "BZD.format": "BZ$ %@",
+ "BZD.variants": "Belize Dollars,Belize Dollar,BZ$",
+ "CAD.format": "$%@ CAD",
+ "CAD.variants": "Canadian Dollars,Canadian Dollar,CA$,$ CAD,$CAD",
+ "CDF.format": "%@ FC",
+ "CDF.variants": "Congolese Francs,Congolese Franc,FC",
+ "CHF.format": "%@ SFr.",
+ "CHF.variants": "Swiss Francs,Swiss Franc,SFr.",
+ "CLP.format": "%@ CLP",
+ "CLP.variants": "Chilean Pesos,Chilean Peso",
+ "CNY.format": "%@ ¥",
+ "CNY.variants": "Chinese Yuans,Chinese Yuan,yuan,yuans,元,RMB",
+ "COP.format": "$%@ COP",
+ "COP.variants": "Colombian Pesos,Colombian Peso",
+ "CRC.format": "%@ ₡",
+ "CRC.variants": "Costa Rican Colones,Costa Rican Colon,Colon,Colones",
+ "CUP.format": "$MN %@",
+ "CUP.variants": "Cuban Pesos,Cuban Peso,$MN",
+ "CVE.format": "%@ CVE",
+ "CVE.variants": "Cape Verdean Escudos,Cape Verdean Escudo,Escudos,Escudo,Esc",
+ "CZK.format": "%@ Kč",
+ "CZK.variants": "Czech Korunas,Czech Koruna,Kč",
+ "DJF.format": "%@ Fdj",
+ "DJF.variants": "Djibouti Francs,Djibouti Franc,Djiboutian Francs,Djiboutian Franc,Fdj",
+ "DKK.format": "%@ kr.",
+ "DKK.variants": "Danish Kroner,Danish Krone,kr.",
+ "DOP.format": "RD$ %@",
+ "DOP.variants": "Dominican Pesos,Dominican Peso,RD$",
+ "DZD.format": "%@ DA",
+ "DZD.variants": "Algerian Dinars,Algerian Dinar,دج",
+ "EGP.format": "£%@ EGP",
+ "EGP.variants": "Egyptian Pounds,Egyptian Pound",
+ "ERN.format": "%@ Nfk",
+ "ERN.variants": "Eritrean nakfa,Nakfa,Nfk",
+ "ETB.format": "%@ Br",
+ "ETB.variants": "Ethiopian Birr,Birr",
+ "EUR.format": "€ %@",
+ "EUR.variants": "Euro,€",
+ "FJD.format": "FJ$ %@",
+ "FJD.variants": "Fiji Dollars,Fiji Dollar,Fijian Dollars,Fijian Dollar,FJ$",
+ "FKP.format": "£ %@",
+ "FKP.variants": "Falkland Islands Pounds,Falkland Islands Pound,Falkland Pounds",
+ "GBP.format": "£ %@",
+ "GBP.variants": "Pound sterling,Pounds sterling,£,£,british pound,british pounds",
+ "GEL.format": "%@ lari",
+ "GEL.variants": "Lari,Georgian Lari",
+ "GHS.format": "GH₵ %@",
+ "GHS.variants": "Ghana Cedi,Cedi,GH₵",
+ "GIP.format": "%@ GIP",
+ "GIP.variants": "Gibraltar Pounds,Gibraltar Pound",
+ "GMD.format": "%@ D",
+ "GMD.variants": "Gambian Dalasi,Dalasi,Gambian Dalasis,Dalasis",
+ "GNF.format": "%@ FG",
+ "GNF.variants": "Guinean franc,Guinean francs",
+ "GTQ.format": "%@ Q",
+ "GTQ.variants": "Guatemalan Quetzales,Guatemalan quetzal,Quetzal,Quetzales",
+ "GYD.format": "G$ %@",
+ "GYD.variants": "Guyanese dollar,Guyanese dollars,G$",
+ "HKD.format": "HK$ %@",
+ "HKD.variants": "Hong Kong Dollar,Hong Kong Dollars,HK$",
+ "HNL.format": "L %@",
+ "HNL.variants": "Honduran Lempira,Honduran Lempiras,Lempira,Lempiras",
+ "HRK.format": "%@ Kn",
+ "HRK.variants": "Croatian Kuna,Kuna",
+ "HTG.format": "%@ G",
+ "HTG.variants": "Haitian Gourdes,Haitian Gourde,Gourdes,Gourde",
+ "HUF.format": "%@ Ft",
+ "HUF.variants": "Hungarian Forint,Forint",
+ "IDR.format": "Rp %@",
+ "IDR.variants": "Indonesian Rupiah,Rp",
+ "ILS.format": "%@ NIS",
+ "ILS.variants": "Israeli new shekel,new shekalim,new sheqalim,₪,NIS",
+ "INR.format": "INR %@",
+ "INR.variants": "Indian Rupee",
+ "IQD.format": "%@ IQD",
+ "IQD.variants": "Iraqi Dinars,Iraqi Dinar,ع.د",
+ "IRR.format": "%@ IR",
+ "IRR.variants": "Iranian Rial,Iranian Rials,IR",
+ "ISK.format": "%@ kr",
+ "ISK.variants": "Icelandic krona,Icelandic kronas,Iceland Krona,Iceland Kronas",
+ "JMD.format": "%@ JMD",
+ "JMD.variants": "Jamaican Dollar,Jamaican Dollars",
+ "JOD.format": "%@ JD",
+ "JOD.variants": "Jordanian Dinars,Jordanian Dinar,دينار,JD",
+ "JPY.format": "¥ %@",
+ "JPY.variants": "Japanese Yen,Yen,¥",
+ "KES.format": "%@ KSh",
+ "KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh",
+ "KGS.format": "%@ sом",
+ "KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,Soms,Som,sом",
+ "KHR.format": "%@ R.",
+ "KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels",
+ "KMF.format": "%@ FC",
+ "KMF.variants": "Comorian Francs,Comorian Franc,FC",
+ "KPW.format": "₩ %@",
+ "KPW.variants": "North Korean won",
+ "KRW.format": "₩ %@",
+ "KRW.variants": "South Korean Won,Won",
+ "KWD.format": "%@ KD",
+ "KWD.variants": "Kuwaiti Dinars,Kuwaiti Dinar,د.ك,KD",
+ "KYD.format": "$ %@",
+ "KYD.variants": "Cayman Islands Dollars,Cayman Islands Dollar",
+ "KZT.format": "%@ T",
+ "KZT.variants": "Kazakhstani Tenge,Tenge",
+ "LAK.format": "%@ K",
+ "LAK.variants": "Lao Kips,Lao Kip,Kips,Kip",
+ "LBP.format": "%@ .ل.ل",
+ "LBP.variants": "Lebanese Pound,Lebanese Pounds,.ل.ل",
+ "LKR.format": "Rs %@",
+ "LKR.variants": "Sri Lanka Rupee",
+ "LRD.format": "L$ %@",
+ "LRD.variants": "Liberian Dollars,Liberian Dollar,L$",
+ "LSL.format": "M %@",
+ "LSL.variants": "Lesotho maloti,Lesotho loti,loti,Maloti",
+ "LTL.format": "%@ Lt",
+ "LTL.variants": "Lithuanian Litas,Lithuanian Litai,Litas,Litai,Lt",
+ "LVL.format": "%@ Ls",
+ "LVL.variants": "Latvian Lats,Lat,Latvian Lati,Lati,Latvian Latu,Latu,Ls",
+ "LYD.format": "%@ LD",
+ "LYD.variants": "Libyan Dinar,Libyan Dinars,د.ل",
+ "MAD.format": "%@ Dh",
+ "MAD.variants": "Moroccan Dirham,Moroccan Dirhams,د.م.",
+ "MDL.format": "%@ lei",
+ "MDL.variants": "Moldovan Leu,Moldovan Lei,Leu",
+ "MGA.format": "Ar %@",
+ "MGA.variants": "Malagasy ariary,ariary",
+ "MKD.format": "%@ den.",
+ "MKD.variants": "Macedonian Denar,Macedonian Denari,den.",
+ "MMK.format": "K %@",
+ "MMK.variants": "Myanmar Kyat,Burmese kyat,kyat",
+ "MNT.format": "%@ ₮",
+ "MNT.variants": "Mongolian Tugrik,Tugrik,₮",
+ "MOP.format": "MOP$ %@",
+ "MOP.variants": "Macanese Pataca,Macao Pataca,Macau Pataca,Pataca,Macanese Patacas,Patacas,Macao Patacas,MOP$",
+ "MRO.format": "%@ UM",
+ "MRO.variants": "Mauritanian ouguiya,ouguiya",
+ "MUR.format": "Rs %@",
+ "MUR.variants": "Mauritius Rupee,Mauritius Rupees",
+ "MVR.format": "Rf %@",
+ "MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf",
+ "MWK.format": "%@ MK",
+ "MWK.variants": "Malawian Kwacha,Kwacha",
+ "MXN.format": "$ %@",
+ "MXN.variants": "Mexican Peso,Mexican Pesos",
+ "MYR.format": "RM %@",
+ "MYR.variants": "Malaysian Ringgit,Ringgit",
+ "MZN.format": "%@ MT",
+ "MZN.variants": "Mozambican Metical,Mozambican Meticais,Metical,Meticais",
+ "NAD.format": "N$ %@",
+ "NAD.variants": "Namibian Dollar,Namibian Dollars,N$",
+ "NGN.format": "%@ ₦",
+ "NGN.variants": "Nigerian Naira,Naira,₦",
+ "NIO.format": "C$ %@",
+ "NIO.variants": "Nicaraguan Cordoba,Cordoba,Cordobas,Nicaraguan Cordobas,C$",
+ "NOK.format": "%@ kr",
+ "NOK.variants": "Norwegian Krone,Norwegian Kroner",
+ "NPR.format": "₨ %@",
+ "NPR.variants": "Nepalese Rupee,Nepalese Rupees,₨",
+ "NZD.format": "$%@ NZD",
+ "NZD.variants": "New Zealand Dollar,New Zealand Dollars",
+ "OMR.format": "%@ RO",
+ "OMR.variants": "Omani Rial,Omani Rials,ر.ع.,RO",
+ "PAB.format": "%@ B/.",
+ "PAB.variants": "Panamanian Balboa,Balboa,Panamanian Balboas,Balboas,B/.",
+ "PEN.format": "%@ S/.",
+ "PEN.variants": "Peruvian Nuevo Sol,Nuevo Sol,Peruvian Nuevo Soles,Nuevo Soles,S/.",
+ "PGK.format": "%@ K",
+ "PGK.variants": "Papua New Guinean Kina,Kina",
+ "PHP.format": "%@ ₱",
+ "PHP.variants": "Philippine Peso,Philippine Pesos,₱",
+ "PKR.format": "Rs %@",
+ "PKR.variants": "Pakistan Rupee,Pakistan Rupees",
+ "PLN.format": "%@ zł",
+ "PLN.variants": "Polish Zloty,Zloty,zł",
+ "PYG.format": "%@ ₲",
+ "PYG.variants": "Paraguayan Guarani,Guarani,Paraguayan Guaranies,Guaranies,₲",
+ "QAR.format": "%@ QR",
+ "QAR.variants": "Qatari Rial,Qatari Rials",
+ "RON.format": "%@ lei",
+ "RON.variants": "Romanian Lei,Romanian Leu",
+ "RSD.format": "%@ RSD",
+ "RSD.variants": "Serbian Dinar,Serbian Dinars",
+ "RUB.format": "%@ ₽",
+ "RUB.variants": "russian rouble,russian roubles,ru rouble,ru roubles,rus roubles,rus rouble,russian ruble,russian rubles,rouble,ruble,rubles,roubles,₽",
+ "RWF.format": "%@ RF",
+ "RWF.variants": "Rwandan franc,Rwandan francs",
+ "SAR.format": "%@ SR",
+ "SAR.variants": "Saudi Riyal,Saudi Riyals,﷼,ر.س",
+ "SBD.format": "SI$ %@",
+ "SBD.variants": "Solomon Islands Dollar,Solomon Islands Dollars,SI$",
+ "SCR.format": "%@ SR",
+ "SCR.variants": "Seychelles Rupees,Seychelles Rupee,Seychellois Rupees,Seychellois Rupee",
+ "SDG.format": "%@ £Sd",
+ "SDG.variants": "Sudanese Pound,Sudanese Pounds,ج.س.,£Sd",
+ "SEK.format": "%@ kr",
+ "SEK.variants": "Swedish Krona,Swedish Kronor",
+ "SGD.format": "S$ %@",
+ "SGD.variants": "Singapore Dollar,Singapore Dollars,S$",
+ "SHP.format": "£%@ SHP",
+ "SHP.variants": "St Helena Pounds,St Helena Pound,Saint Helena pound,Saint Helena pounds",
+ "SLL.format": "%@ Le",
+ "SLL.variants": "Sierra Leonean Leone,Sierra Leonean Leones,Leone,Leones",
+ "SOS.format": "%@ So.",
+ "SOS.variants": "Somali Shillings,Somali Shilling,Sh.,So.",
+ "SRD.format": "$ %@",
+ "SRD.variants": "Surinamese Dollar,Surinamese Dollars",
+ "SSP.format": "%@ SSP",
+ "SSP.variants": "South Sudanese pound,South Sudanese pounds",
+ "STD.format": "%@ Db",
+ "STD.variants": "São Tomé and Príncipe Dobra,São Tomé and Príncipe Dobras,Dobra,Dobras",
+ "SVC.format": "%@ ₡",
+ "SVC.variants": "El Salvador Colon,Salvadoran colon,Salvadoran colones",
+ "SYP.format": "S£ %@",
+ "SYP.variants": "Syrian Pound,Syrian Pounds,S£",
+ "SZL.format": "E %@",
+ "SZL.variants": "Swazi Lilangeni,Lilangeni,Swazi Emalangeni,Emalangeni",
+ "THB.format": "฿ %@",
+ "THB.variants": "Thai Baht,Baht,฿",
+ "TJS.format": "%@ TJS",
+ "TJS.variants": "Tajikistani Somoni,Somoni",
+ "TMT.format": "%@ TMT",
+ "TMT.variants": "Turkmenistan Manat,Manat",
+ "TND.format": "%@ TD",
+ "TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD",
+ "TOP.format": "T$ %@",
+ "TOP.variants": "Tongan Paʻanga,Paʻanga,T$",
+ "TRY.format": "₺%@",
+ "TRY.variants": "Turkish Lira,₺",
+ "TTD.format": "TT$ %@",
+ "TTD.variants": "Trinidad and Tobago Dollar,Trinidad and Tobago Dollars,TT$",
+ "TWD.format": "NT$ %@",
+ "TWD.variants": "New Taiwan Dollars,New Taiwan Dollar,NT$,NT Dollar, NTD",
+ "TZS.format": "%@ TSh",
+ "TZS.variants": "Tanzanian Shilling,Tanzanian Shillings,Tanzanian Shillingi,TSh",
+ "UAH.format": "%@ UAH",
+ "UAH.variants": "Ukrainian Hryvnia,Ukrainian Hryvni,Ukrainian Hryven',Hryvnia,Hryvni,Hryven',₴,grn.",
+ "UGX.format": "%@ USh",
+ "UGX.variants": "Ugandan Shilling,Ugandan Shillings,USh",
+ "USD.format": "$%@",
+ "USD.variants": "dollar USA,dollars USA,dollar,USD,dollars,U.S. Dollars,U.S. Dollar,USA Dollars,USA Dollar,$",
+ "UYU.format": "$U %@",
+ "UYU.variants": "Uruguayan Peso,Uruguayan Pesos,$U",
+ "UZS.format": "%@ som",
+ "UZS.variants": "Uzbekistani som,som,so'm",
+ "VEF.format": "Bs.F. %@",
+ "VEF.variants": "Venezuelan Bolívar,Venezuelan Bolivar,Venezuelan Bolívares,Venezuelan Bolivares,bolívar fuerte,bolivar fuerte,bolívares fuertes,Bs.F.",
+ "VND.format": "%@ ₫",
+ "VND.variants": "Vietnamese Dong,Dong,₫",
+ "VUV.format": "%@ VT",
+ "VUV.variants": "Vanuatu Vatu,Vatu",
+ "WST.format": "WS$ %@",
+ "WST.variants": "Samoan Tala,Tala,WS$",
+ "XAF.format": "%@ FCFA",
+ "XAF.variants": "CFA Franc (Beac),Central African CFA franc,Central African CFA francs,CFA francs,CFA franc,Franc CFA BEAC,FCFA",
+ "XCD.format": "EC$ %@",
+ "XCD.variants": "East Caribbean Dollar,East Caribbean Dollars,EC$",
+ "XOF.format": "%@ CFA",
+ "XOF.variants": "West African CFA franc,franc CFA BCEAO,CFA",
+ "XPF.format": "%@ F",
+ "XPF.variants": "CFP Franc,CFP Francs,franc pacifique,francs pacifique",
+ "YER.format": "%@ YER",
+ "YER.variants": "Yemeni Rial,Yemeni Rials,﷼",
+ "ZAR.format": "R %@",
+ "ZAR.variants": "South African Rand,Rand",
+ "ZMW.format": "%@ ZMK",
+ "ZMW.variants": "Zambian Kwacha,Kwacha,ZMK"
+ },
+ "Data": {
+ "bit.format": "b",
+ "bit.symbol": "b",
+ "bit.variants": "bit,bits,b",
+ "byte.format": "B",
+ "byte.symbol": "B",
+ "byte.variants": "byte,bytes,B"
+ },
+ "Dates": {
+ "current_time.variants": "time,now",
+ "local_time.variants": "local,local timezone,local time zone",
+ "today.variants": "today",
+ "tomorrow.variants": "tomorrow",
+ "yesterday.variants": "yesterday"
+ },
+ "IECPrefixes": {
+ "exbi.prefix": "exbi",
+ "exbi.symbol": "Ei",
+ "gibi.prefix": "gibi",
+ "gibi.symbol": "Gi",
+ "kibi.prefix": "kibi",
+ "kibi.symbol": "Ki",
+ "mebi.prefix": "mebi",
+ "mebi.symbol": "Mi",
+ "pebi.prefix": "pebi",
+ "pebi.symbol": "Pi",
+ "tebi.prefix": "tebi",
+ "tebi.symbol": "Ti",
+ "yobi.prefix": "yobi",
+ "yobi.symbol": "Yi",
+ "zebi.prefix": "zebi",
+ "zebi.symbol": "Zi"
+ },
+ "Length": {
+ "cable.format": "cable",
+ "cable.variants": "cable,cables",
+ "chain.format": "chain",
+ "chain.variants": "chain,chains",
+ "foot.format": "ft",
+ "foot.variants": "foot,foots,ft,ft.,feet,feets",
+ "furlong.format": "furlong",
+ "furlong.variants": "furlong,furlongs",
+ "inch.format": "″",
+ "inch.variants": "inches,inch,″",
+ "league.format": "league",
+ "league.variants": "league,leagues",
+ "meter.format": "m",
+ "meter.symbol": "m",
+ "meter.variants": "meter,metre,metres,meters,m",
+ "mile.format": "mi.",
+ "mile.variants": "mile,miles,mi,mi.",
+ "nautical_mile.format": "n.m.",
+ "nautical_mile.variants": "nmi,nautical mile,nautical miles,n.m.",
+ "point.format": "pt",
+ "point.variants": "point,points,pt,pt.",
+ "yard.format": "yd",
+ "yard.variants": "yard,yards,yd,yd."
+ },
+ "Numbers": {
+ "binary.variants": "binary,bin",
+ "decimal.variants": "decimal,dec",
+ "hex.variants": "hex",
+ "octal.variants": "octal",
+ "scientific.variants": "scientific, sci, exp, exponent, exponential"
+ },
+ "Operations": {
+ "assignment.variants": "equal,is",
+ "avg.variants": "average,avg",
+ "cbrt.variants": "cbrt,cubic root,cube root,cubed root",
+ "conversion.variants": "in,into,as,to",
+ "divide.variants": "divide,divide by,divided by",
+ "minus.variants": "minus,without,subtract",
+ "multiply.variants": "times,multiplied by,multiply,mul,mult",
+ "plus.variants": "with,plus,and",
+ "prev.variants": "prev",
+ "sqrt.variants": "sqrt,square root",
+ "sum.variants": "sum,total"
+ },
+ "Percent": {
+ "as_a_percent_of.variants": "as a % of,as a percent of",
+ "as_a_percent_off.variants": "as a % off,as a percent off,as a % decrease of,as a percent decrease of",
+ "as_a_percent_on.variants": "as a % on,as a percent on,as a % increase of,as a percent increase of",
+ "percent.format": "%%",
+ "percent.variants": "%,percent,percents,pct,pct.",
+ "percent_of.variants": "from,of",
+ "percent_of_what_is.variants": "of what is",
+ "percent_off.variants": "off",
+ "percent_off_what_is.variants": "off what is",
+ "percent_on.variants": "on",
+ "percent_on_what_is.variants": "on what is"
+ },
+ "SIPrefixes": {
+ "atto.prefix": "atto",
+ "atto.symbol": "a",
+ "centi.prefix": "centi",
+ "centi.symbol": "c",
+ "deca.prefix": "deca",
+ "deca.symbol": "da",
+ "deci.prefix": "deci",
+ "deci.symbol": "d",
+ "exa.prefix": "exa",
+ "exa.symbol": "E",
+ "femto.prefix": "femto",
+ "femto.symbol": "f",
+ "giga.prefix": "giga",
+ "giga.symbol": "G",
+ "hecto.prefix": "hecto",
+ "hecto.symbol": "h",
+ "kilo.prefix": "kilo",
+ "kilo.symbol": "k",
+ "mega.prefix": "mega",
+ "mega.symbol": "M",
+ "micro.prefix": "micro",
+ "micro.symbol": "µ",
+ "milli.prefix": "milli",
+ "milli.symbol": "m",
+ "nano.prefix": "nano",
+ "nano.symbol": "n",
+ "peta.prefix": "peta",
+ "peta.symbol": "P",
+ "pico.prefix": "pico",
+ "pico.symbol": "p",
+ "tera.prefix": "tera",
+ "tera.symbol": "T",
+ "yocto.prefix": "yocto",
+ "yocto.symbol": "y",
+ "yotta.prefix": "yotta",
+ "yotta.symbol": "Y",
+ "zepto.prefix": "zepto",
+ "zepto.symbol": "z",
+ "zetta.prefix": "zetta",
+ "zetta.symbol": "Z"
+ },
+ "Samples": {
+ "sample.welcome": "# Summarum\n8 / (45 - 20%)\n5 inches in cm\n$9 in euro\n100 km/h in mph"
+ },
+ "Scales": {
+ "billion.variants": "billion,Billion,milliard,Milliard,billions,Billions,milliards,Milliards",
+ "million.variants": "million,Million,millions,Millions,M",
+ "thousand.variants": "thousand,Thousand,thousands,Thousands,th.,th,k"
+ },
+ "Temperature": {
+ "celsius.format": "°C",
+ "celsius.variants": "celsius,C,°C",
+ "fahrenheit.format": "°F",
+ "fahrenheit.variants": "fahrenheit,fahrenheits,F,°F",
+ "kelvin.format": "K",
+ "kelvin.variants": "kelvin,kelvins,K"
+ },
+ "Time": {
+ "day.format": "day",
+ "day.variants": "day,days,d",
+ "hour.format": "h",
+ "hour.variants": "h,hr,hour,hours",
+ "minute.format": "min",
+ "minute.variants": "min,minute,minutes",
+ "month.format": "mon.",
+ "month.variants": "month,monthes,months,mon,mon.",
+ "second.format": "s",
+ "second.symbol": "s",
+ "second.variants": "s,sec,second,seconds",
+ "week.format": "w",
+ "week.variants": "week,w,weeks",
+ "year.format": "yr",
+ "year.variants": "y,yr,year,years"
+ },
+ "Volume": {
+ "cbcm.variants": "cc,ccm",
+ "cbinch.variants": "c.i.,CI,cui,cu inch",
+ "cup.format": "cup",
+ "cup.variants": "cup,cups",
+ "gallon.format": "gal.",
+ "gallon.variants": "gallon,gallons,gal.,gal",
+ "litre.format": "L",
+ "litre.symbol": "l",
+ "litre.variants": "litre,litres,liter,liters,l,L",
+ "pint.format": "pint",
+ "pint.variants": "pint,pints",
+ "quart.format": "qt.",
+ "quart.variants": "quart,quarts,qt.,qt",
+ "tablespoon.format": "tbsp.",
+ "tablespoon.variants": "tablespoon,tablespoons,table spoon,table spoons,tbsp.,tbsp,T.",
+ "teaspoon.format": "tsp.",
+ "teaspoon.variants": "teaspoon,teaspoons,tea spoon,tea spoons,t.,tsp.,tsp",
+ "volume.format": "%@³",
+ "volume.templates": "cu %@,cubic %@,cb %@,cb%@,%@³,%@ ³,%@ 3"
+ },
+ "Weight": {
+ "carat.format": "ct",
+ "carat.variants": "carat,carats,CD,ct",
+ "centner.format": "centner",
+ "centner.variants": "centner,quintal,centners,quintals",
+ "gram.format": "g",
+ "gram.symbol": "g",
+ "gram.variants": "gram,gramme,grams,grammes,g",
+ "ounce.format": "oz",
+ "ounce.variants": "ounce,ounces,oz",
+ "pound.format": "lb",
+ "pound.variants": "pound,pounds,lb,lbm",
+ "stone.format": "st",
+ "stone.variants": "stone,stones,st",
+ "tonne.format": "t",
+ "tonne.symbol": "t",
+ "tonne.variants": "tonne,tonnes,t,metric ton"
+ }
+};
+
+export const RU: VocabLocale = {
+ "Angle": {
+ "degree.format": "°,градус,градусов,градусы",
+ "degree.variants": "°,градус,градусов,градусах,градуса,град,град.",
+ "radian.format": "рад",
+ "radian.variants": "радиан,рад,радианах"
+ },
+ "Area": {
+ "acre.format": "акр",
+ "acre.variants": "акр,акров,акра,акрах",
+ "are.format": "а",
+ "are.variants": "ар,ара,арах,аров",
+ "hectare.format": "га",
+ "hectare.variants": "гектар,гектаров,гектара,гектарах,га",
+ "ping.format": "пинг",
+ "ping.variants": "пинг,пинги"
+ },
+ "Css": {
+ "pixel.variants": "пиксел,пиксела,пикселу,пикселом,пикселе,пиксель,пикселя,пикселю,пекселем,пиксели,пикселей,пикселям,пикселями,пикселях,пикс."
+ },
+ "Currency": {
+ "AED.variants": "Дирхам ОАЭ,Дирхамы ОАЭ,Дирхам",
+ "AFN.variants": "Афгани",
+ "ALL.format": "%@ Lek",
+ "ALL.variants": "Лек,Албанский лек",
+ "AMD.format": "%@ Драм",
+ "AMD.variants": "Драм,Драмов,Драмы,Армянский драм,Армянские драмы,Армянских драмов",
+ "ANG.format": "%@ ƒ",
+ "ANG.variants": "Гульден,Гульденов,Гульдены,Гульденах",
+ "AOA.variants": "Кванза,Кванзы,Ангольская кванза,Ангольские кванзы,кванзах",
+ "ARS.variants": "Аргентинское песо,Аргентинских песо",
+ "AUD.variants": "Австралийский доллар,Австралийских долларов,Австралийских долларах",
+ "AWG.format": "%@ ƒ",
+ "AWG.variants": "Арубанский флорин,Арубанских флоринов,Арубанских флоринах",
+ "AZN.format": "%@ ман.",
+ "AZN.variants": "Азербайджанский манат,Азербайджанских манат,Азербайджанских манатах,манат,манатах,man.,ман.",
+ "BAM.variants": "Конвертируемая марка,Конвертируемых марок,Конвертируемых марках",
+ "BBD.variants": "Барбадосский доллар,Барбадосские доллары,Барбадосских долларов,Барбадосских долларах",
+ "BDT.variants": "Така,Таков,Таках,Таки",
+ "BGN.format": "%@ лв",
+ "BGN.variants": "Болгарский лев,Болгарских левов,Болгарские левы,Болгарских левах,Лев,Левов,Левах,Болг. левов,Болг. лев,Болг. левах",
+ "BHD.variants": "Бахрейнский динар,Бахрейнских динаров,Бахрейнских динарах,Динар,Динарах,Динаров",
+ "BIF.variants": "Бурундийский франк,Бурундийских франков,Бурундийские франки,Бурундийских франках",
+ "BMD.format": "%@ $",
+ "BMD.variants": "Бермудский доллар,Бермудских долларов,Бермудских долларах,Бермудские доллары",
+ "BND.format": "%@ B$",
+ "BND.variants": "Брунейский доллар,Брунейских долларов,Брунейских долларах,Брунейские доллары",
+ "BOB.format": "%@ Bs",
+ "BOB.variants": "Боливиано",
+ "BRL.format": "%@ R$",
+ "BRL.variants": "Бразильский реал,Бразильских реалов,Бразильских реалах,Бразильские реалы,Реал,Реалов,Реалах",
+ "BSD.format": "%@ B$",
+ "BSD.variants": "Багамский доллар,Багамских долларов,Багамских долларах,Багамские доллары",
+ "BTC.variants": "Bitcoins,Bitcoin,Биткоин,Биткойн,Биткоинов,Биткойнов,Биткоинах,Биткойнах,Биткоины,Биткойны",
+ "BTN.format": "%@ Nu",
+ "BTN.variants": "Нгултрум,Нгултрумах,Нгултрумов",
+ "BWP.variants": "Пула,Пул,Пулах",
+ "BYN.format": "%@ BYN",
+ "BYN.variants": "белорусский рубль,белорусских рублей,белорусских рублях,белорусских рубля,бел. рублях,бел. рубль,бел. рублей,бел. рубля,бел руб,бел руб.,бел. руб.,Br",
+ "BZD.format": "%@ BZ$",
+ "BZD.variants": "Белизский доллар,Белизских долларах,Белизских долларов,Белизские доллары",
+ "CAD.format": "%@ CA$",
+ "CAD.variants": "Канадский доллар,Канадских долларов,Канадских долларах,Канадские доллары",
+ "CDF.variants": "Конголезский франк,Конголезских франков,Конголезских франках,Конголезские франки",
+ "CHF.format": "%@ SFr",
+ "CHF.variants": "Швейцарский франк,Швейцарских франков,Швейцарских франках,Швейцарские франки,Франк,Франков,Франках",
+ "CLP.variants": "Чилийское песо,Чилийских песо",
+ "CNY.variants": "Юань,Юаней,Юаня,Юанях",
+ "COP.variants": "Колумбийское песо,Колумбийские песо,Колумбийских песо",
+ "CRC.format": "%@ C",
+ "CRC.variants": "Костариканский колон,Костариканских колонах,Костариканских колон,Колон,Колонах",
+ "CUP.format": "%@ $MN",
+ "CUP.variants": "Кубинское песо,Кубинский песо,Кубинских песо",
+ "CVE.variants": "Эскудо Кабо-Верде,Эскудо",
+ "CZK.variants": "Чешская крона,Чешские кроны,Чешских крон,Чешских кронах",
+ "DJF.variants": "Франк Джибути,Франков Джибути,Франках Джибути",
+ "DKK.format": "%@ kr",
+ "DKK.variants": "Датские кроны,Датская Крона,Датских кронах,Датских крон",
+ "DOP.format": "%@ RD$",
+ "DOP.variants": "Доминиканское песо,Золотой песо,Доминиканских песо",
+ "DZD.variants": "Алжирские динары,Алжирский динар,Алжирских динар,Алжирских динарах",
+ "EGP.format": "%@ EGP",
+ "EGP.variants": "Египетские фунты,Египетский Фунт,Египетских фунтов,Египетских Фунтах",
+ "ERN.variants": "Накфа,Накфах,Накф",
+ "ETB.variants": "Эфиопский быр,Быр,Быров,Бырах",
+ "EUR.format": "%@ €",
+ "EUR.variants": "Евро",
+ "FJD.format": "%@ FJ$",
+ "FJD.variants": "Доллар Фиджи,Долларах Фиджи,Долларов Фиджи",
+ "FKP.format": "%@ £",
+ "FKP.variants": "Фунт Фолклендских островов,Фолклендский фунт,Фолклендских фунтов,Фолклендских фунтах",
+ "GBP.format": "%@ £",
+ "GBP.variants": "Фунт стерлингов,Фунтов стерлингов,Фунтах стерлингов,Британский фунт,Британских фунтов,Фунт,Фунтов,Британских фунтах,Фунтах",
+ "GEL.variants": "Грузинский лари,Лари",
+ "GHS.format": "%@ GH₵",
+ "GHS.variants": "Ганский седи,Седи",
+ "GIP.variants": "Гибралтарский фунт,Гибралтарских фунтов,Гибралтарских фунтах",
+ "GMD.variants": "Даласи,Гамбийский даласи",
+ "GNF.format": "%@ GFr",
+ "GNF.variants": "Гвинейский франк,Гвинейских франках,Гвинейских франков",
+ "GTQ.variants": "Гватемальский кетсаль,Кетсаль,Кетцаль,Кетсалей,Кетсалях",
+ "GYD.format": "%@ GY$",
+ "GYD.variants": "Гайанские доллары,Гайанский доллар,Гайанских долларах,Гайанских долларов",
+ "HKD.format": "%@ HK$",
+ "HKD.variants": "Гонконгские доллары,Гонконгский доллар,Гонконгских долларов,Гонконгских долларах",
+ "HNL.format": "%@ L",
+ "HNL.variants": "Гондурасская лемпира,Лемпира,Лемпирах,Лемпир",
+ "HRK.variants": "Хорватская куна,Хорватских кунах,Куна,Кунах",
+ "HTG.variants": "Гурд,Гаитянский гурд,Гурдов,Гурдах",
+ "HUF.format": "%@ ft",
+ "HUF.variants": "Форинт,Форинтов,Форинтах,Венгерский форинт",
+ "IDR.format": "%@ Rp",
+ "IDR.variants": "Индонезийские рупии,Индонезийская рупия,Индонезийских рупиях,Индонезийских рупий",
+ "ILS.variants": "Новый израильский шекель,Новый шекель,Новых шекелей,новых шекелях",
+ "INR.format": "%@ Re.",
+ "INR.variants": "Индийские рупии,Индийская рупия,Индийских рупиях,Индийских рупий",
+ "IQD.format": "%@ ID",
+ "IQD.variants": "Иракские Динары,Иракский Динар,Иракских Динарах,Иракских Динаров",
+ "IRR.variants": "Иранские риалы,Иранский риал,Иранских риалов,Иранских риалах,Риал,Риалах,Риалов",
+ "ISK.variants": "Исландские кроны,Исландская крона,Исландских крон,Исландских кронах",
+ "JMD.variants": "Ямайские доллары,Ямайский доллар,Ямайских долларов,Ямайских долларах",
+ "JOD.variants": "Иорданские динары,Иорданский динар,Иорданских динаров,Иорданских динарах",
+ "JPY.format": "%@ ¥",
+ "JPY.variants": "японская иена,иена,иен,иены,йен,йены,йена,японская йена",
+ "KES.variants": "Кенийские шиллинги,Кенийский шиллинг,Кенийских шиллингов,Кенийских шиллингах",
+ "KGS.format": "%@ сом",
+ "KGS.variants": "Кыргызский сом,Сом,Сомов,Сомах,Сома",
+ "KHR.variants": "Камбоджийский Риель,Риель,Риелей,Риелях",
+ "KMF.variants": "Франк Комор,Коморский франк,Коморских франках",
+ "KPW.format": "%@ ₩",
+ "KPW.variants": "Северокорейская вона,Вона КНДР,Северокорейских вон,Северокорейских вонах",
+ "KRW.format": "%@ ₩",
+ "KRW.variants": "Южнокорейская вона,Вона,вон,вонах,Южнокорейских вонах",
+ "KWD.variants": "Кувейтский динар,Кувейтских динарах,Кувейтских динаров",
+ "KYD.format": "%@ $",
+ "KYD.variants": "Доллар Островов Кайман,Доллар Каймановых островов,Долларах Каймановых островов",
+ "KZT.variants": "Казахстанский Тенге,Тенге",
+ "LAK.variants": "Лаосский кип,Кип,Кипах",
+ "LBP.variants": "Ливанские Фунты,Ливанский Фунт,Ливанских фунтах",
+ "LKR.format": "%@ Rs",
+ "LKR.variants": "Шри-Ланкийская рупия,Шри-Ланкийских рупий,Шри-Ланкийских рупиях",
+ "LRD.format": "%@ L$",
+ "LRD.variants": "Либерийские Доллары,Либерийский доллар,Либерийских долларах",
+ "LSL.format": "%@ M",
+ "LSL.variants": "Лоти,Лоти Лесото,Малоти",
+ "LTL.variants": "Литовский лит,Лит,Литах",
+ "LVL.variants": "Латвийский лат,Лат,Латах",
+ "LYD.variants": "Ливийские динары,Ливийский динар,Ливийских динарах,Ливийских динар",
+ "MAD.variants": "Марокканский дирхам,Дирхам,Дирхамов,Дирхамах",
+ "MDL.variants": "Молдавский лей,Молдавских леев,Молдавских леях,Лей,молд. лей,молд. леях,молд. леев",
+ "MGA.format": "%@ Ar.",
+ "MGA.variants": "Малагасийский ариари,Ариари",
+ "MKD.format": "%@ ден.",
+ "MKD.variants": "Македонский денар,Денар,Денарах",
+ "MMK.format": "%@ K",
+ "MMK.variants": "Кьят,Кьятах",
+ "MNT.variants": "Монгольский тугрик,Тугрик,Тугриков,Тугриках",
+ "MOP.format": "%@ $",
+ "MOP.variants": "Патака Макао,Патака,Патак,Патаках",
+ "MRO.variants": "Мавританская угия,Угия,Угий,Угиях",
+ "MUR.format": "%@ Rs",
+ "MUR.variants": "Маврикийская рупия,Маврикийских рупий,Маврикийских рупиях",
+ "MVR.format": "%@ Rf",
+ "MVR.variants": "Мальдивская руфия,Руфия,Руфий,Руфиях",
+ "MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах,Квача,Квачах",
+ "MXN.format": "%@ $",
+ "MXN.variants": "Мексиканских песо,Мексиканское песо",
+ "MYR.format": "%@ RM",
+ "MYR.variants": "Малайзийский ринггит,Ринггит,Ринггитах",
+ "MZN.variants": "Мозамбикский метикал,Метикал,Метикалов,Метикалах",
+ "NAD.format": "%@ N$",
+ "NAD.variants": "Доллар Намибии,Намибийский доллар,Намибийских долларов,Намибийских долларах",
+ "NGN.variants": "Найра,Нигерийская найра,Найр,Найрах",
+ "NIO.format": "%@ C$",
+ "NIO.variants": "Никарагуанская кордоба,Золотая Кордоба,Кордоба,Кордоб,Кордобах",
+ "NOK.variants": "Крона,Норвежская крона,Норвежских крон,Норвежских кронах,Крон,Кронах",
+ "NPR.format": "%@ ₨",
+ "NPR.variants": "Непальские рупии,Непальская рупия,Непальских рупий,Непальских рупиях",
+ "NZD.variants": "Новозеландские доллары,Новозеландский доллар,Новозеландских долларах,Новозеландских долларов",
+ "OMR.variants": "Оманские риалы,Оманский риал,Оманских риалов,Оманских риала",
+ "PAB.format": "%@ B",
+ "PAB.variants": "Панамский бальбоа,Бальбоа",
+ "PEN.variants": "Новый соль,Перуанский Новый соль,Солей,Новых солей,Новых солях,Солях",
+ "PGK.variants": "Кина",
+ "PHP.format": "%@ PhP",
+ "PHP.variants": "Филиппинское песо,Филиппинских песо",
+ "PKR.format": "%@ Rs",
+ "PKR.variants": "Пакистанских рупий,Пакистанских рупиях,Пакистанская рупия",
+ "PLN.variants": "Польский злотый,Злотый,Злотых,Злотые",
+ "PYG.format": "%@ G",
+ "PYG.variants": "Парагвайский гуарани,Гуарани",
+ "QAR.variants": "Катарские риалы,Катарский риал,Катарских риалах",
+ "RON.variants": "Румынский лей,Новый румынский лей,Лей,Леев,Леях,Румынских леев,Румынских леях",
+ "RSD.format": "%@ дин.",
+ "RSD.variants": "Сербские динары,Сербский динар,Сербских динарах,Сербских динар",
+ "RUB.variants": "Российский рубль,Российских рублей,Российских рублях,Российские рубли,Рос. рубль,Рос. рублей,Рос. рублях,Рос. руб.,росс. руб.,росс руб,рос руб.,рубль,руб,руб.,₽,рубля,рублю,рублем,рубле,рубли,рублей,рублями,рублях",
+ "RWF.variants": "Франк Руанды,Руандийский франк,Руандийских франков,Руандийских франках",
+ "SAR.variants": "Саудовский риял,Риял Саудовской аравии,Риял,Риялов,Риялах,Саудовских риялах",
+ "SBD.format": "%@ SI$",
+ "SBD.variants": "Доллар Соломоновых Островов,Долларов Соломоновых Островов,Долларах Соломоновых Островов",
+ "SCR.variants": "Сейшельские рупии,Сейшельская рупия,Сейшельских рупиях,Сейшельских рупий",
+ "SDG.format": "%@ £",
+ "SDG.variants": "Суданские фунты,Суданский фунт,Суданских фунтов,Суданских фунтах",
+ "SEK.variants": "Шведские кроны,Шведская крона,Шведских крон,Шведских кронах",
+ "SGD.format": "%@ S$",
+ "SGD.variants": "Сингапурские доллары,Сингапурский доллар,Сингапурских долларов,Сингапурских долларах",
+ "SHP.format": "%@ £",
+ "SHP.variants": "Фунт Святой Елены,Фунтов Святой Елены,Фунтах Святой Елены",
+ "SLL.variants": "Леоне",
+ "SOS.variants": "Сомалийские шиллингы,Сомалийский шиллинг,Шиллинг,Шиллингов,Шиллингах,Сомалийских шиллингах",
+ "SRD.format": "%@ $",
+ "SRD.variants": "Суринамский доллар,Суринамских долларов,Суринамских долларах",
+ "SSP.variants": "Южносуданский фунт,Южно-суданский фунт,Южносуданских фунтов,Южносуданских фунтах",
+ "STD.variants": "Добра,Добр,Добрах",
+ "SVC.format": "%@ C",
+ "SVC.variants": "Сальвадорский колон,Колонов,Колон,Колонах",
+ "SYP.format": "%@ S£",
+ "SYP.variants": "Сирийские фунты,Сирийский фунт,Сирийских фунтов,Сирийских фунтах",
+ "SZL.format": "%@ E",
+ "SZL.variants": "Свазилендский лилангени,Эмалангени,Лилангени",
+ "THB.format": "%@ B",
+ "THB.variants": "Тайский бат,Бат,Батов,Батах",
+ "TJS.format": "%@ смн.",
+ "TJS.variants": "Таджикский сомони,Сомони",
+ "TMT.variants": "Туркменский манат,Новый туркменский манат,Манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
+ "TND.variants": "Тунисские Динары,Тунисский Динар,Тунисских Динаров,Тунисских Динарах",
+ "TOP.format": "%@ T$",
+ "TOP.variants": "Тонганская паанга,Паанга,Паангах",
+ "TRY.variants": "Турецкая лира,Турецких лир,Турецких лирах,Турецкие лиры",
+ "TTD.format": "%@ TT$",
+ "TTD.variants": "Доллар Тринидада и Тобаго,Доллар,Долларов Тринидада и Тобаго,Долларах Тринидада и Тобаго",
+ "TWD.format": "%@ NT$",
+ "TWD.variants": "Новые тайваньские доллары,Новый тайваньский доллар,Новых тайваньских долларах,Новых тайваньских долларов",
+ "TZS.variants": "Танзанийские шиллинги,Танзанийский шиллинг,Танзанийских шиллингов,Танзанийских шиллингах",
+ "UAH.format": "%@ грн.",
+ "UAH.variants": "Украинские гривны,Украинская гривна,Гривна,Гривнах,Гривен,грн.",
+ "UGX.variants": "Угандийские шиллинги,Угандийский шиллинг,Угандийских шиллингов,Угандийских шиллингах",
+ "USD.format": "%@ $",
+ "USD.variants": "Доллары США,Доллар США,Доллар Соединенных Штатов Америки,USD,ам. доллар,амер. доллар,долларов сша,долларах сша,доллары сша,доллар,доллара,долларом,доллары,долларов,долларами,долларах,$",
+ "UYU.format": "%@ $U",
+ "UYU.variants": "Уругвайское песо,Уругвайских песо",
+ "UZS.format": "%@ so’m",
+ "UZS.variants": "Узбекский сум,Сум,Сумов,Сумах,Узбекских сумах,Узбекских сумов",
+ "VEF.format": "%@ BsF",
+ "VEF.variants": "Венесуэльские боливары,Венесуэльский Боливар,Боливар фуэрте,Венесуэльских боливаров,Венесуэльских боливарах,Боливарах фуэрте",
+ "VND.format": "%@ dd",
+ "VND.variants": "Вьетнамский донг,Донг,Донгов,Донгах",
+ "VUV.format": "%@ Vt",
+ "VUV.variants": "Вануатский вату,Вату",
+ "WST.format": "%@ WS$",
+ "WST.variants": "Самоанская тала,Тала,Тал,Талах",
+ "XAF.format": "%@ BEAC",
+ "XAF.variants": "Франк КФА BEAC,Франк КФА,Франков КФА,Франках КФА",
+ "XCD.format": "%@ EC$",
+ "XCD.variants": "Восточно-карибские доллары,Восточно-карибский доллар,Восточно-карибских долларов,Восточно-карибских долларах",
+ "XOF.format": "%@ BCEAO",
+ "XOF.variants": "Франк КФА BCEAO,Франк Африканского финансового сообщества,BCEAO",
+ "XPF.variants": "Французский тихоокеанский франк,Франк КФП,Франков КФП,Франках КФП",
+ "YER.format": "%@ YR",
+ "YER.variants": "Йеменские риалы,Йеменский риал,Йеменских риалов,Йеменских риалах",
+ "ZAR.format": "%@ R",
+ "ZAR.variants": "Южноафриканский рэнд,Южноафриканский ранд,Ранд,Рэнд,Рэндов,Рэндах,рандов,Рандах",
+ "ZMW.format": "%@ K",
+ "ZMW.variants": "Замбийская квача,Квача,Квач,Квачах"
+ },
+ "Data": {
+ "bit.format": "бит",
+ "bit.symbol": "бит",
+ "bit.variants": "бит,битов,битах,битам,биты",
+ "byte.format": "Б",
+ "byte.symbol": "Б",
+ "byte.variants": "байт,байтов,байтам,байтах,Б,байты"
+ },
+ "Dates": {
+ "current_time.variants": "время,сейчас,time,now",
+ "local_time.variants": "local,local timezone,local time zone,местное время,локальное время,локально",
+ "today.variants": "сегодня",
+ "tomorrow.variants": "завтра",
+ "yesterday.variants": "вчера"
+ },
+ "IECPrefixes": {
+ "exbi.prefix": "эксби",
+ "exbi.symbol": "Эи",
+ "gibi.prefix": "гиби",
+ "gibi.symbol": "Ги",
+ "kibi.prefix": "киби",
+ "kibi.symbol": "Ки",
+ "mebi.prefix": "меби",
+ "mebi.symbol": "Ми",
+ "pebi.prefix": "пеби",
+ "pebi.symbol": "Пи",
+ "tebi.prefix": "теби",
+ "tebi.symbol": "Ти",
+ "yobi.prefix": "йоби",
+ "yobi.symbol": "Йи",
+ "zebi.prefix": "зеби",
+ "zebi.symbol": "Зи"
+ },
+ "Length": {
+ "cable.format": "кабельт",
+ "cable.variants": "кабельт,кабельтов,кабельтах,кабельта",
+ "chain.format": "чейн",
+ "chain.variants": "чейн,чейнов,чейнах,чейна",
+ "foot.format": "фут",
+ "foot.variants": "фут,футов,футах,фута",
+ "furlong.format": "фарлонг",
+ "furlong.variants": "фарлонг,фарлонгов,фарлонгах,фарлонга",
+ "inch.format": "дюйм",
+ "inch.variants": "дюйм,дюймов,дюймах,дюйма",
+ "league.format": "лига",
+ "league.variants": "лига,лиги,лигах",
+ "meter.format": "м",
+ "meter.symbol": "м",
+ "meter.variants": "метров,метрах,метр,метра,м",
+ "mile.format": "миля",
+ "mile.variants": "миля,миль,милях,мили",
+ "nautical_mile.format": "морская миля",
+ "nautical_mile.variants": "морская миля,морских миль,морских милях,морских мили",
+ "point.format": "точка",
+ "point.variants": "точка,точек,точках,точки",
+ "yard.format": "ярд",
+ "yard.variants": "ярд,ярдов,ярдах,ярда"
+ },
+ "Operations": {
+ "assignment.variants": "равно,соответствует",
+ "avg.variants": "среднее,средн.,средн",
+ "cbrt.variants": "кубический корень,куб корень,куб. корень",
+ "conversion.variants": "в,как",
+ "divide.variants": "разделить,разделить на,делим,делим на",
+ "minus.variants": "минус,без",
+ "multiply.variants": "раз по,раз,умножить,по",
+ "plus.variants": "и,плюс,вместе с",
+ "prev.variants": "пред.,пред",
+ "sqrt.variants": "квадратный корень,кв корень,кв. корень",
+ "sum.variants": "всего,сумма,итог,итого"
+ },
+ "Percent": {
+ "as_a_percent_of.variants": "как процент от,как процент",
+ "as_a_percent_off.variants": "как процентное уменьшение от,как процентное уменьшение для,как процентное уменьшение из",
+ "as_a_percent_on.variants": "как процентная добавка к,как процентное увеличение для,как процентное увеличение к",
+ "percent.variants": "процент,процента,проценту,процентом,проценте,проценты,процентов,процентам,процентами,процентах,проц,проц.",
+ "percent_of.variants": "от,из",
+ "percent_of_what_is.variants": "чего является,чего есть,от чего является,от чего есть,от чего,чего",
+ "percent_off.variants": "скидка от,снятие из,снятие от,снятие ото,уменьшение из,уменьшение для,уменьшение к",
+ "percent_off_what_is.variants": "уменьшение от чего,снятие от чего,уменьшение чего,снятие чего",
+ "percent_on.variants": "добавка к,прибавка к,увеличение к,увеличение для",
+ "percent_on_what_is.variants": "увеличение чего,увеличение от чего,добавление к чему"
+ },
+ "SIPrefixes": {
+ "atto.prefix": "атто",
+ "atto.symbol": "а",
+ "centi.prefix": "санти",
+ "centi.symbol": "с",
+ "deca.prefix": "дека",
+ "deca.symbol": "да",
+ "deci.prefix": "деци",
+ "deci.symbol": "д",
+ "exa.prefix": "экса",
+ "exa.symbol": "Э",
+ "femto.prefix": "фемто",
+ "femto.symbol": "ф",
+ "giga.prefix": "гига",
+ "giga.symbol": "Г",
+ "hecto.prefix": "гекто",
+ "hecto.symbol": "ч",
+ "kilo.prefix": "кило",
+ "kilo.symbol": "к",
+ "mega.prefix": "мега",
+ "mega.symbol": "М",
+ "micro.prefix": "микро",
+ "micro.symbol": "мк",
+ "milli.prefix": "милли",
+ "milli.symbol": "м",
+ "nano.prefix": "нано",
+ "nano.symbol": "н",
+ "peta.prefix": "пета",
+ "peta.symbol": "П",
+ "pico.prefix": "пико",
+ "pico.symbol": "п",
+ "tera.prefix": "тера",
+ "tera.symbol": "Т",
+ "yocto.prefix": "иокто",
+ "yocto.symbol": "и",
+ "yotta.prefix": "иотта",
+ "yotta.symbol": "И",
+ "zepto.prefix": "зепто",
+ "zepto.symbol": "з",
+ "zetta.prefix": "зетта",
+ "zetta.symbol": "З"
+ },
+ "Samples": {
+ "sample.welcome": "# Summarum\n8 / (45 - 20%)\n5 дюймов в см\n$9 в евро\n100 км/ч в милях"
+ },
+ "Scales": {
+ "billion.variants": "миллиард,миллиардов,миллиарда,млрд,млрд.",
+ "million.variants": "миллион,мил.,мил,милл.,милл,миллиона,миллионов,М,млн,млн.",
+ "thousand.variants": "тысяч,тысячь,тысячи,тысяча,тыс.,тыс,т,т."
+ },
+ "Temperature": {
+ "celsius.variants": "цельсий,цельсия,цельсиев,цельсиях,цельсиями,градусов цельсия",
+ "fahrenheit.variants": "градус фаренгейта,градуса фаренгейта,градусу фаренгейта,градусом фаренгейта,градусы фаренгейта,градусов фаренгейта,градусам фаренгейта,градусами фаренгейта,градусах фаренгейта",
+ "kelvin.variants": "кельвин,градус кельвина,градуса кельвина,градусов кельвина,кельвинов,кельвина,кельвине,кельвинах"
+ },
+ "Time": {
+ "day.format": "д",
+ "day.variants": "день,дня,дней,днях,д",
+ "hour.format": "ч",
+ "hour.variants": "час,часа,часов,часах",
+ "minute.format": "мин",
+ "minute.variants": "мин,мин.,минута,минуты,минут,минутах",
+ "month.format": "мес.",
+ "month.variants": "месяц,месяца,месяцев,месяцах,мес,мес.",
+ "second.format": "с",
+ "second.symbol": "с",
+ "second.variants": "сек,секунда,секунды,секунд,секундах,сек.",
+ "week.format": "нед.",
+ "week.variants": "неделя,недели,недель,неделях,нед.,нед",
+ "year.format": "г",
+ "year.variants": "год,года,лет,годах"
+ },
+ "Volume": {
+ "cbcm.variants": "см³",
+ "cbinch.variants": "дюйм³",
+ "cup.format": "чаш.",
+ "cup.variants": "чашка,чашек,чашках,чашку,чашкам,чаш.,чашк,чашк.,чаш,чашки",
+ "gallon.format": "гал.",
+ "gallon.variants": "галлон,гал.,гал,галлона,галлонов,галлонах,галлоне",
+ "litre.format": "л",
+ "litre.symbol": "л",
+ "litre.variants": "литр,литров,литрах,литра,л",
+ "pint.format": "пинт.",
+ "pint.variants": "пинт,пинта,пинтах",
+ "quart.format": "кварт.",
+ "quart.variants": "кварта,четверть,кварт.,кварт,квартах,кварте",
+ "tablespoon.format": "ст. л.",
+ "tablespoon.variants": "ст. л.,ст. ложка,столовая ложка,столовой ложки,столовой ложке,столовых ложек,столовые ложки,столовыми ложками,столовых ложках,ст.л.",
+ "teaspoon.format": "ч. л.",
+ "teaspoon.variants": "ч. л.,ч.л.,чайная ложка,ч. ложка,ч. ложках,ч. ложки,ч. ложками,чайных ложки,чайных ложках,чайной ложке,чайные ложки",
+ "volume.templates": "cu %@,cubic %@,cb %@,cb%@,%@³,%@ ³,куб. %@,кубический %@"
+ },
+ "Weight": {
+ "carat.format": "кар",
+ "carat.variants": "карата,карат,каратах,каратов,кар",
+ "centner.format": "ц",
+ "centner.variants": "центнер,центнеров,центнерах,центнера",
+ "gram.format": "г",
+ "gram.symbol": "г",
+ "gram.variants": "грамм,граммов,грамма,граммах",
+ "ounce.format": "унция",
+ "ounce.variants": "унция,унциях,унции",
+ "pound.format": "фунт",
+ "pound.variants": "фунт,фунтов,фунтах,фунта",
+ "stone.format": "стоун",
+ "stone.variants": "стоун,стоунах,стоуна,стоунов",
+ "tonne.format": "т",
+ "tonne.symbol": "т",
+ "tonne.variants": "тонны,тонна,тонн,тоннах"
+ }
+};
diff --git a/src/engine/vocab.ts b/src/engine/vocab.ts
new file mode 100644
index 0000000..32604b4
--- /dev/null
+++ b/src/engine/vocab.ts
@@ -0,0 +1,72 @@
+// Merged phrase lists over the en + ru vocabulary tables.
+// Shape: { Category: { "key.variants": "a,b,c", "key.format": "...", ... } }
+import { EN, RU } from "./vocab-data";
+
+export type VocabCategory = Record;
+export type VocabLocale = Record;
+
+const locales: VocabLocale[] = [EN, RU];
+
+/** All values for `category.key` across locales, comma-split, trimmed, deduped. */
+export function variants(category: string, key: string): string[] {
+ const out: string[] = [];
+ for (const loc of locales) {
+ const raw = loc[category]?.[key];
+ if (!raw) continue;
+ for (const v of raw.split(",")) {
+ const t = v.trim();
+ if (t && !out.includes(t)) out.push(t);
+ }
+ }
+ return out;
+}
+
+/** Single value (first locale that has it), e.g. formats. */
+export function entry(category: string, key: string): string | undefined {
+ for (const loc of locales) {
+ const raw = loc[category]?.[key];
+ if (raw !== undefined) return raw;
+ }
+ return undefined;
+}
+
+/** English-only entry (formats should not be localized). */
+export function entryEn(category: string, key: string): string | undefined {
+ return EN[category]?.[key];
+}
+
+/** The entry value from every locale that defines it (e.g. unit symbols "m" and "м"). */
+export function entriesAll(category: string, key: string): string[] {
+ const out: string[] = [];
+ for (const loc of locales) {
+ const raw = loc[category]?.[key];
+ if (raw && !out.includes(raw)) out.push(raw);
+ }
+ return out;
+}
+
+/** All base keys in a category that end with `.variants` (e.g. unit ids). */
+export function idsOf(category: string): string[] {
+ const ids = new Set();
+ for (const loc of locales) {
+ const cat = loc[category];
+ if (!cat) continue;
+ for (const k of Object.keys(cat)) {
+ if (k.endsWith(".variants")) ids.add(k.slice(0, -".variants".length));
+ }
+ }
+ return [...ids];
+}
+
+/** All keys of a category ending with a suffix, e.g. `.prefix` for SI prefixes. */
+export function idsWithSuffix(category: string, suffix: string): string[] {
+ const ids = new Set();
+ for (const loc of locales) {
+ const cat = loc[category];
+ if (!cat) continue;
+ for (const k of Object.keys(cat)) {
+ if (k.endsWith(suffix)) ids.add(k.slice(0, -suffix.length));
+ }
+ }
+ return [...ids];
+}
diff --git a/src/extensions.ts b/src/extensions.ts
new file mode 100644
index 0000000..e7d9214
--- /dev/null
+++ b/src/extensions.ts
@@ -0,0 +1,29 @@
+// JS extensions runtime (Numi-compatible API): numi.setVariable,
+// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
+import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
+
+export interface ExtensionApi {
+ setVariable(name: string, value: number | ExtensionValue): void;
+ addUnit(spec: ExtensionUnitSpec): void;
+ addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void;
+}
+
+export function makeApi(engine: SumEngine): ExtensionApi {
+ return {
+ setVariable: (name, value) => engine.setVariable(name, value),
+ addUnit: (spec) => engine.addUnit(spec),
+ addFunction: (spec, fn) => engine.addFunction(spec, fn),
+ };
+}
+
+export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
+ const numi = makeApi(engine);
+ for (const s of scripts) {
+ try {
+ const fn = new Function("numi", s.code);
+ fn(numi);
+ } catch (e) {
+ console.error(`extension ${s.name} failed:`, e);
+ }
+ }
+}
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..490bc0e
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,101 @@
+/** UI localization (en/ru). The engine itself understands both languages at once. */
+
+const dict: Record> = {
+ en: {
+ settings: "Settings",
+ theme: "Theme",
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+ precision: "Decimal places",
+ groupsep: "Thousands separator",
+ language: "Language",
+ hotkey: "Global hotkey",
+ autostart: "Launch at startup",
+ fontsize: "Font size",
+ extensions: "Extensions folder",
+ done: "Done",
+ newDocument: "New document",
+ untitled: "Untitled",
+ copied: "Copied",
+ ratesUpdated: "Rates updated",
+ ratesOffline: "Offline rates",
+ deleteDoc: "Delete document?",
+ pressKeys: "Press keys…",
+ total: "Total",
+ resultswidth: "Results column",
+ selection: "Selection",
+ rates: "Rates",
+ justNow: "just now",
+ minAgo: "{}m ago",
+ hourAgo: "{}h ago",
+ dayAgo: "{}d ago",
+ refreshRates: "Click to refresh",
+ bindays: "Bin retention (days)",
+ datadir: "Data folder",
+ backups: "Backups",
+ defaultFolder: "Default",
+ folderConflict: "The selected folder already contains Summarum sheets. Use them, or replace with the current ones?",
+ useExisting: "Use existing",
+ replaceMine: "Replace with mine",
+ folderChanged: "Data folder changed",
+ },
+ ru: {
+ settings: "Настройки",
+ theme: "Тема",
+ system: "Системная",
+ light: "Светлая",
+ dark: "Тёмная",
+ precision: "Знаков после запятой",
+ groupsep: "Разделитель тысяч",
+ language: "Язык",
+ hotkey: "Глобальный хоткей",
+ autostart: "Запуск при старте Windows",
+ fontsize: "Размер шрифта",
+ extensions: "Папка расширений",
+ done: "Готово",
+ newDocument: "Новый документ",
+ untitled: "Без названия",
+ copied: "Скопировано",
+ ratesUpdated: "Курсы обновлены",
+ ratesOffline: "Курсы офлайн",
+ deleteDoc: "Удалить документ?",
+ pressKeys: "Нажмите клавиши…",
+ total: "Сумма",
+ resultswidth: "Колонка результатов",
+ selection: "Выделение",
+ rates: "Курсы",
+ justNow: "только что",
+ minAgo: "{} мин назад",
+ hourAgo: "{} ч назад",
+ dayAgo: "{} дн назад",
+ refreshRates: "Клик — обновить",
+ bindays: "Корзина (дней)",
+ datadir: "Папка данных",
+ backups: "Бэкапы",
+ defaultFolder: "По умолчанию",
+ folderConflict: "В выбранной папке уже есть листы Summarum. Использовать их или заменить текущими?",
+ useExisting: "Использовать их",
+ replaceMine: "Заменить моими",
+ folderChanged: "Папка данных изменена",
+ },
+};
+
+let lang = "en";
+
+export function setLang(l: string): void {
+ lang = dict[l] ? l : "en";
+ document.documentElement.lang = lang;
+ for (const el of document.querySelectorAll("[data-i18n]")) {
+ const key = el.dataset.i18n!;
+ el.textContent = t(key);
+ }
+}
+
+export function t(key: string): string {
+ return dict[lang]?.[key] ?? dict.en[key] ?? key;
+}
+
+export function detectLang(): string {
+ return navigator.language?.toLowerCase().startsWith("ru") ? "ru" : "en";
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..a017af9
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,576 @@
+/** App bootstrap: engine + editor + documents + settings + Tauri glue. */
+import "./ui/app.css";
+import { SumEngine } from "./engine";
+import { SumEditor } from "./ui/editor";
+import {
+ AppData, DocMeta, SettingsData, defaultSettingsData,
+ loadAppData, saveAppData, loadSettings, saveSettings,
+ fetchRates, loadExtensionScripts, openExtensionsFolder, isTauri,
+ getLaunchFile, onOpenFile, onFileDrop,
+ setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
+ chooseFolder, dataDirHasDocuments, migrateDataDir,
+} from "./storage";
+import type { LineResult } from "./engine";
+import { setLang, detectLang, t } from "./i18n";
+import { runExtensions } from "./extensions";
+import * as vocabApi from "./engine/vocab";
+
+const WELCOME = (vocabApi.entry("Samples", "sample.welcome") ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n";
+
+const $ = (sel: string): T => document.querySelector(sel) as T;
+
+let engine: SumEngine;
+let editor: SumEditor;
+let settings: SettingsData;
+let data: AppData;
+let lastResults: LineResult[] = [];
+let selectedRange: [number, number] | null = null;
+let ratesFetchedAt = 0;
+
+function uid(): string {
+ return Math.random().toString(36).slice(2, 10);
+}
+
+// ---------- documents
+
+function activeDoc(): DocMeta {
+ return data.docs.find((d) => d.id === data.activeId) ?? data.docs[0];
+}
+
+function titleFromContent(text: string): string {
+ for (const line of text.split("\n")) {
+ const s = line.trim();
+ if (s.length > 0) return s.replace(/^#\s*/, "").slice(0, 30);
+ }
+ return t("untitled");
+}
+
+function syncTitleField(): void {
+ const field = $("#doc-title");
+ if (document.activeElement !== field) field.value = activeDoc()?.title ?? "";
+}
+
+function renderDocList(): void {
+ const list = $("#doc-list");
+ list.replaceChildren();
+ for (const doc of data.docs) {
+ const el = document.createElement("div");
+ el.className = "doc-item" + (doc.id === data.activeId ? " active" : "");
+ const name = document.createElement("span");
+ name.textContent = doc.title || t("untitled");
+ el.appendChild(name);
+ const del = document.createElement("button");
+ del.className = "del";
+ del.textContent = "✕";
+ // two-step inline confirmation instead of the native dialog
+ let confirmTimer: ReturnType | null = null;
+ del.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (data.docs.length === 1) return;
+ if (!del.classList.contains("confirm")) {
+ del.classList.add("confirm");
+ del.textContent = "✓";
+ confirmTimer = setTimeout(() => {
+ del.classList.remove("confirm");
+ del.textContent = "✕";
+ }, 2000);
+ return;
+ }
+ if (confirmTimer) clearTimeout(confirmTimer);
+ // the sheet goes to backups/deleted/ — restore by dragging it back
+ void backupDeletedSheet(settings.dataDir, doc.title, data.contents[doc.id] ?? "");
+ delete data.contents[doc.id];
+ data.docs = data.docs.filter((d) => d.id !== doc.id);
+ if (data.activeId === doc.id) switchDoc(data.docs[0].id);
+ else renderDocList();
+ saveAppData(data);
+ });
+ el.appendChild(del);
+ el.addEventListener("click", () => switchDoc(doc.id));
+ list.appendChild(el);
+ }
+}
+
+function switchDoc(id: string): void {
+ data.activeId = id;
+ editor.setText(data.contents[id] ?? "");
+ syncTitleField();
+ renderDocList();
+ saveAppData(data);
+ editor.focus();
+}
+
+function newDoc(content = ""): void {
+ const id = uid();
+ data.docs.push({ id, title: content ? titleFromContent(content) : t("untitled") });
+ data.contents[id] = content;
+ switchDoc(id);
+}
+
+// ---------- settings
+
+function applySettings(): void {
+ const theme = settings.theme === "system"
+ ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
+ : settings.theme;
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
+ $("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
+ setLang(settings.language);
+ engine.updateSettings({ precision: settings.precision, groupSeparator: settings.groupSeparator });
+ editor?.refresh();
+ syncTitleField();
+ $("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
+}
+
+function bindSettingsUI(): void {
+ const themeSel = $("#set-theme");
+ const precision = $("#set-precision");
+ const groupSep = $("#set-groupsep");
+ const langSel = $("#set-lang");
+ const hotkey = $("#set-hotkey");
+ const autostart = $("#set-autostart");
+ const fontSize = $("#set-fontsize");
+ const resultsWidth = $("#set-resultswidth");
+
+ themeSel.value = settings.theme;
+ precision.value = String(settings.precision);
+ groupSep.value = settings.groupSeparator;
+ langSel.value = settings.language;
+ hotkey.value = settings.hotkey;
+ autostart.checked = settings.autostart;
+ fontSize.value = String(settings.fontSize);
+ resultsWidth.value = String(settings.resultsWidth);
+
+ const save = () => {
+ settings.theme = themeSel.value as SettingsData["theme"];
+ settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
+ settings.groupSeparator = groupSep.value;
+ settings.language = langSel.value;
+ settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
+ settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
+ applySettings();
+ void saveSettings(settings);
+ };
+ for (const el of [themeSel, precision, groupSep, langSel, fontSize]) {
+ el.addEventListener("change", save);
+ }
+ resultsWidth.addEventListener("input", save); // live while sliding
+ // hotkey is recorded, not typed
+ hotkey.readOnly = true;
+ hotkey.addEventListener("focus", () => {
+ hotkey.value = "";
+ hotkey.placeholder = t("pressKeys");
+ });
+ hotkey.addEventListener("blur", () => {
+ hotkey.value = settings.hotkey;
+ hotkey.placeholder = defaultSettingsData.hotkey;
+ });
+ hotkey.addEventListener("keydown", async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === "Escape") {
+ hotkey.blur();
+ return;
+ }
+ const mods = [
+ e.ctrlKey && "Ctrl",
+ e.altKey && "Alt",
+ e.shiftKey && "Shift",
+ e.metaKey && "Super",
+ ].filter(Boolean) as string[];
+ if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
+ hotkey.value = mods.length ? mods.join("+") + "+…" : "";
+ return;
+ }
+ if (mods.length === 0) return; // a bare key is not a global hotkey
+ const keyName = normalizeKeyName(e);
+ if (!keyName) return;
+ const combo = [...mods, keyName].join("+");
+ const old = settings.hotkey;
+ settings.hotkey = combo;
+ hotkey.value = combo;
+ await registerHotkey(old, combo);
+ void saveSettings(settings);
+ hotkey.blur();
+ });
+ autostart.addEventListener("change", async () => {
+ settings.autostart = autostart.checked;
+ await applyAutostart(settings.autostart);
+ void saveSettings(settings);
+ });
+
+ const binDays = $("#set-bindays");
+ binDays.value = String(settings.deletedRetentionDays);
+ binDays.addEventListener("change", () => {
+ settings.deletedRetentionDays = Math.max(1, Math.min(365, Math.round(Number(binDays.value)) || 14));
+ binDays.value = String(settings.deletedRetentionDays);
+ void saveSettings(settings);
+ void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
+ });
+
+ const dataDirBtn = $("#set-datadir");
+ const renderDataDir = () => {
+ dataDirBtn.textContent = settings.dataDir ? settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir : t("defaultFolder");
+ dataDirBtn.title = settings.dataDir || t("defaultFolder");
+ };
+ renderDataDir();
+ dataDirBtn.addEventListener("click", async () => {
+ const picked = await chooseFolder();
+ if (!picked || picked === settings.dataDir) return;
+ let strategy: "move" | "overwrite" | "use_existing" = "move";
+ if (await dataDirHasDocuments(picked)) {
+ const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine"));
+ if (ans === null) return;
+ strategy = ans === "a" ? "use_existing" : "overwrite";
+ }
+ await migrateDataDir(settings.dataDir, picked, strategy);
+ settings.dataDir = picked;
+ setDataDir(picked);
+ await saveSettings(settings);
+ renderDataDir();
+ if (strategy === "use_existing") {
+ const fresh = await loadAppData(picked);
+ if (fresh && fresh.docs.length > 0) {
+ data = fresh;
+ if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
+ switchDoc(data.activeId);
+ }
+ }
+ toast(t("folderChanged"));
+ });
+
+ $("#open-settings").addEventListener("click", () => {
+ $("#settings-panel").classList.toggle("hidden");
+ });
+ $("#close-settings").addEventListener("click", () => {
+ $("#settings-panel").classList.add("hidden");
+ });
+ $("#open-extensions").addEventListener("click", () => void openExtensionsFolder());
+ $("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir));
+
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
+ if (settings.theme === "system") applySettings();
+ });
+}
+
+/** Physical key (e.code) → Tauri accelerator name, layout-independent (works on ru). */
+function normalizeKeyName(e: KeyboardEvent): string | null {
+ const code = e.code;
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3);
+ if (/^Digit\d$/.test(code)) return code.slice(5);
+ if (/^Numpad\d$/.test(code)) return code;
+ if (/^F\d{1,2}$/.test(code)) return code;
+ const map: Record = {
+ Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
+ Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
+ ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right",
+ Minus: "-", Equal: "=", Comma: ",", Period: ".", Slash: "/", Backquote: "`",
+ BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'",
+ };
+ return map[code] ?? null;
+}
+
+// ---------- tauri integration
+
+async function registerHotkey(old: string | null, combo: string): Promise {
+ if (!isTauri()) return;
+ try {
+ const gs = await import("@tauri-apps/plugin-global-shortcut");
+ if (old) await gs.unregister(old).catch(() => {});
+ await gs.register(combo, async (e) => {
+ if (e.state !== "Pressed") return;
+ const { getCurrentWindow } = await import("@tauri-apps/api/window");
+ const win = getCurrentWindow();
+ if (await win.isVisible() && await win.isFocused()) {
+ await win.hide();
+ } else {
+ await win.show();
+ await win.setFocus();
+ }
+ });
+ } catch (e) {
+ console.warn("hotkey registration failed", e);
+ }
+}
+
+async function applyAutostart(enabled: boolean): Promise {
+ if (!isTauri()) return;
+ try {
+ const auto = await import("@tauri-apps/plugin-autostart");
+ if (enabled) await auto.enable();
+ else await auto.disable();
+ } catch (e) {
+ console.warn("autostart failed", e);
+ }
+}
+
+async function refreshRates(force = false): Promise {
+ const info = $("#rates-info");
+ info.classList.add("spin");
+ const payload = await fetchRates(force);
+ info.classList.remove("spin");
+ if (payload) {
+ engine.setRates(payload.rates);
+ editor.refresh();
+ ratesFetchedAt = payload.fetchedAt;
+ if (force) toast(t("ratesUpdated"));
+ }
+ renderRatesInfo();
+}
+
+// ---------- status bar
+
+/** total of the selection (≥2 lines) or of the whole sheet */
+function renderTotal(): void {
+ const line = $("#total-line");
+ let results = lastResults;
+ let label = t("total");
+ if (selectedRange && selectedRange[1] > selectedRange[0]) {
+ results = lastResults.slice(selectedRange[0], selectedRange[1] + 1);
+ label = t("selection");
+ }
+ const total = engine.totalOf(results);
+ if (total) {
+ line.innerHTML = "";
+ const lbl = document.createElement("span");
+ lbl.className = "total-label";
+ lbl.textContent = label;
+ line.append(lbl, document.createTextNode(total));
+ line.dataset.value = total;
+ line.style.display = "";
+ } else {
+ line.style.display = "none";
+ delete line.dataset.value;
+ }
+}
+
+function agoText(unixSec: number): string {
+ const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
+ if (s < 90) return t("justNow");
+ if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
+ if (s < 86400) return t("hourAgo").replace("{}", String(Math.round(s / 3600)));
+ return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
+}
+
+function renderRatesInfo(): void {
+ const el = $("#rates-info");
+ if (ratesFetchedAt > 0) {
+ el.textContent = `${t("rates")}: ${agoText(ratesFetchedAt)}`;
+ el.title = t("refreshRates");
+ } else {
+ el.textContent = `${t("rates")}: ⚠ offline`;
+ el.title = t("ratesOffline");
+ }
+}
+
+// ---------- modal
+
+/** two-button modal; resolves "a" | "b" | null (click outside = cancel) */
+function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
+ return new Promise((resolve) => {
+ const modal = $("#modal");
+ const a = $("#modal-a");
+ const b = $("#modal-b");
+ $("#modal-msg").textContent = msg;
+ a.textContent = aLabel;
+ b.textContent = bLabel;
+ a.classList.add("primary");
+ modal.classList.remove("hidden");
+ const done = (res: "a" | "b" | null) => {
+ modal.classList.add("hidden");
+ a.onclick = b.onclick = modal.onclick = null;
+ resolve(res);
+ };
+ a.onclick = () => done("a");
+ b.onclick = () => done("b");
+ modal.onclick = (e) => {
+ if (e.target === modal) done(null);
+ };
+ });
+}
+
+// ---------- toast
+
+let toastTimer: ReturnType | null = null;
+function toast(msg: string): void {
+ const el = $("#toast");
+ el.textContent = msg;
+ el.classList.remove("hidden");
+ if (toastTimer) clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => el.classList.add("hidden"), 1200);
+}
+
+// ---------- boot
+
+async function boot(): Promise {
+ settings = await loadSettings();
+ if (!localStorage.getItem("numi.langInit") && settings.language === "en") {
+ settings.language = detectLang();
+ localStorage.setItem("numi.langInit", "1");
+ }
+
+ engine = new SumEngine({ precision: settings.precision, groupSeparator: settings.groupSeparator });
+
+ // snapshots run before the app writes anything
+ setDataDir(settings.dataDir);
+ await runBackups(settings.dataDir, settings.deletedRetentionDays);
+
+ const stored = await loadAppData(settings.dataDir);
+ if (stored && stored.docs.length > 0) {
+ data = stored;
+ } else {
+ const id = uid();
+ data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: WELCOME } };
+ }
+ if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
+
+ const scripts = await loadExtensionScripts();
+ runExtensions(engine, scripts);
+
+ editor = new SumEditor(
+ $("#editor"),
+ $("#results"),
+ engine,
+ {
+ onChange(text) {
+ const doc = activeDoc();
+ data.contents[doc.id] = text;
+ if (!doc.customTitle) {
+ doc.title = titleFromContent(text);
+ syncTitleField();
+ }
+ renderDocList();
+ saveAppData(data);
+ },
+ onCopy(text) {
+ void navigator.clipboard.writeText(text);
+ toast(t("copied"));
+ },
+ onResults(results) {
+ lastResults = results;
+ renderTotal();
+ },
+ onSelection(range) {
+ selectedRange = range;
+ renderTotal();
+ },
+ },
+ data.contents[data.activeId] ?? "",
+ );
+
+ applySettings(); // sets the language before bindSettingsUI renders dynamic labels
+ bindSettingsUI();
+ renderDocList();
+ syncTitleField();
+
+ const titleField = $("#doc-title");
+ titleField.addEventListener("change", () => {
+ const doc = activeDoc();
+ const v = titleField.value.trim();
+ if (v) {
+ doc.title = v.slice(0, 60);
+ doc.customTitle = true;
+ } else {
+ doc.customTitle = false;
+ doc.title = titleFromContent(data.contents[doc.id] ?? "");
+ }
+ syncTitleField();
+ renderDocList();
+ saveAppData(data);
+ });
+ titleField.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === "Escape") titleField.blur();
+ });
+
+ $("#total-line").addEventListener("click", () => {
+ const v = $("#total-line").dataset.value;
+ if (v) {
+ void navigator.clipboard.writeText(v);
+ toast(t("copied"));
+ }
+ });
+
+ if (isTauri()) {
+ const { getCurrentWindow } = await import("@tauri-apps/api/window");
+ const win = getCurrentWindow();
+ $("#win-min").addEventListener("click", () => void win.minimize());
+ $("#win-close").addEventListener("click", () => void win.hide());
+ } else {
+ $("#win-controls").style.display = "none";
+ }
+
+ $("#toggle-sidebar").addEventListener("click", () => {
+ settings.sidebarVisible = !settings.sidebarVisible;
+ $("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
+ void saveSettings(settings);
+ });
+ $("#new-doc").addEventListener("click", () => newDoc());
+
+ document.addEventListener("mousedown", (e) => {
+ if (!settings.sidebarVisible) return;
+ const target = e.target as HTMLElement;
+ if (target.closest("#sidebar") || target.closest("#toggle-sidebar")) return;
+ settings.sidebarVisible = false;
+ $("#sidebar").classList.add("hidden");
+ void saveSettings(settings);
+ });
+
+ const divider = $("#col-divider");
+ const wrap = $("#editor-wrap");
+ divider.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ divider.classList.add("dragging");
+ document.body.style.cursor = "col-resize";
+ const onMove = (ev: MouseEvent) => {
+ const rect = wrap.getBoundingClientRect();
+ const pct = ((rect.right - ev.clientX) / rect.width) * 100;
+ settings.resultsWidth = Math.round(Math.max(20, Math.min(60, pct)));
+ wrap.style.setProperty("--results-width", `${settings.resultsWidth}%`);
+ };
+ const onUp = () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ divider.classList.remove("dragging");
+ document.body.style.cursor = "";
+ $("#set-resultswidth").value = String(settings.resultsWidth);
+ void saveSettings(settings);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "n") {
+ e.preventDefault();
+ newDoc();
+ }
+ if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") {
+ e.preventDefault();
+ $("#toggle-sidebar").click();
+ }
+ if (e.ctrlKey && e.shiftKey && e.code === "KeyC") {
+ e.preventDefault();
+ void navigator.clipboard.writeText(editor.getSheetWithResults());
+ toast(t("copied"));
+ }
+ });
+
+ $("#rates-info").addEventListener("click", () => void refreshRates(true));
+ setInterval(renderRatesInfo, 60_000);
+
+ void onFileDrop((content) => newDoc(content));
+
+ await registerHotkey(null, settings.hotkey);
+ if (settings.autostart) await applyAutostart(true);
+ void refreshRates();
+ setInterval(() => void refreshRates(), 60 * 60 * 1000);
+
+ const launched = await getLaunchFile();
+ if (launched) newDoc(launched);
+ void onOpenFile((content) => newDoc(content));
+
+ editor.focus();
+}
+
+void boot();
diff --git a/src/storage.ts b/src/storage.ts
new file mode 100644
index 0000000..50ab269
--- /dev/null
+++ b/src/storage.ts
@@ -0,0 +1,241 @@
+// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
+
+export interface DocMeta {
+ id: string;
+ title: string;
+ /** user renamed the document — stop deriving the title from the first line */
+ customTitle?: boolean;
+}
+
+export interface AppData {
+ docs: DocMeta[];
+ activeId: string;
+ contents: Record;
+}
+
+export interface SettingsData {
+ theme: "system" | "light" | "dark";
+ precision: number;
+ groupSeparator: string;
+ language: string;
+ hotkey: string;
+ autostart: boolean;
+ fontSize: number;
+ sidebarVisible: boolean;
+ /** results column width, % of the editor area */
+ resultsWidth: number;
+ /** custom folder for documents.json + backups; "" = default app data dir */
+ dataDir: string;
+ /** how long deleted sheets stay in backups/deleted */
+ deletedRetentionDays: number;
+}
+
+export const defaultSettingsData: SettingsData = {
+ theme: "system",
+ precision: 2,
+ groupSeparator: ",",
+ language: "en",
+ hotkey: "Ctrl+Alt+N",
+ autostart: false,
+ fontSize: 15,
+ sidebarVisible: false,
+ resultsWidth: 42,
+ dataDir: "",
+ deletedRetentionDays: 14,
+};
+
+/** "" -> undefined for the Rust side (default app data dir) */
+function dirArg(dataDir: string): string | null {
+ return dataDir.trim() ? dataDir : null;
+}
+
+export function isTauri(): boolean {
+ return "__TAURI_INTERNALS__" in window;
+}
+
+async function invoke(cmd: string, args?: Record): Promise {
+ const { invoke } = await import("@tauri-apps/api/core");
+ return invoke(cmd, args);
+}
+
+// ---------- settings
+
+export async function loadSettings(): Promise {
+ try {
+ const raw = isTauri()
+ ? await invoke("load_file", { name: "settings.json" })
+ : localStorage.getItem("numi.settings");
+ if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
+ } catch (e) {
+ console.warn("loadSettings failed", e);
+ }
+ return { ...defaultSettingsData };
+}
+
+export async function saveSettings(s: SettingsData): Promise {
+ const raw = JSON.stringify(s, null, 2);
+ if (isTauri()) await invoke("save_file", { name: "settings.json", contents: raw });
+ else localStorage.setItem("numi.settings", raw);
+}
+
+// ---------- documents
+
+export async function loadAppData(dataDir: string): Promise {
+ try {
+ const raw = isTauri()
+ ? await invoke("load_documents", { dir: dirArg(dataDir) })
+ : localStorage.getItem("numi.documents");
+ if (raw) return JSON.parse(raw);
+ } catch (e) {
+ console.warn("loadAppData failed", e);
+ }
+ return null;
+}
+
+let saveTimer: ReturnType | null = null;
+let currentDataDir = "";
+
+export function setDataDir(dataDir: string): void {
+ currentDataDir = dataDir;
+}
+
+export function saveAppData(data: AppData): void {
+ // debounce: autosave on every keystroke without disk churn
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(async () => {
+ const raw = JSON.stringify(data);
+ try {
+ if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
+ else localStorage.setItem("numi.documents", raw);
+ } catch (e) {
+ console.warn("saveAppData failed", e);
+ }
+ }, 400);
+}
+
+// ---------- backups & data folder
+
+export async function runBackups(dataDir: string, retentionDays: number): Promise {
+ if (!isTauri()) return;
+ try {
+ await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
+ } catch (e) {
+ console.warn("runBackups failed", e);
+ }
+}
+
+export async function backupDeletedSheet(dataDir: string, title: string, contents: string): Promise {
+ if (!isTauri() || !contents.trim()) return;
+ try {
+ await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
+ } catch (e) {
+ console.warn("backupDeletedSheet failed", e);
+ }
+}
+
+export async function openBackupsFolder(dataDir: string): Promise {
+ if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
+}
+
+export async function chooseFolder(): Promise {
+ if (!isTauri()) return null;
+ const { open } = await import("@tauri-apps/plugin-dialog");
+ const picked = await open({ directory: true, multiple: false });
+ return typeof picked === "string" ? picked : null;
+}
+
+export async function dataDirHasDocuments(dir: string): Promise {
+ if (!isTauri()) return false;
+ return invoke("data_dir_has_documents", { dir });
+}
+
+export type MigrateStrategy = "move" | "overwrite" | "use_existing";
+
+export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise {
+ if (!isTauri()) return;
+ await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy });
+}
+
+// ---------- rates
+
+export interface RatesPayload {
+ date: string;
+ rates: Record;
+ /** unix seconds when the rates were actually fetched */
+ fetchedAt: number;
+}
+
+export async function fetchRates(force = false): Promise {
+ try {
+ if (isTauri()) {
+ return await invoke("fetch_rates", { force });
+ }
+ const res = await fetch("https://open.er-api.com/v6/latest/USD");
+ const json = await res.json();
+ if (json?.result === "success") {
+ return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
+ }
+ } catch (e) {
+ console.warn("fetchRates failed", e);
+ }
+ return null;
+}
+
+// ---------- extensions
+
+export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
+ if (!isTauri()) return [];
+ try {
+ return await invoke<{ name: string; code: string }[]>("load_extensions");
+ } catch (e) {
+ console.warn("loadExtensions failed", e);
+ return [];
+ }
+}
+
+export async function openExtensionsFolder(): Promise {
+ if (isTauri()) await invoke("open_extensions_folder");
+}
+
+// ---------- .numi file association
+
+export async function getLaunchFile(): Promise {
+ if (!isTauri()) return null;
+ try {
+ return await invoke("get_launch_file");
+ } catch {
+ return null;
+ }
+}
+
+export async function onOpenFile(cb: (content: string) => void): Promise {
+ if (!isTauri()) return;
+ const { listen } = await import("@tauri-apps/api/event");
+ await listen("open-file", (e) => cb(e.payload));
+}
+
+/** Drag & drop of .numi/.txt/.md files: Tauri native events or HTML5 fallback. */
+export async function onFileDrop(cb: (content: string) => void): Promise {
+ if (isTauri()) {
+ const { getCurrentWebview } = await import("@tauri-apps/api/webview");
+ await getCurrentWebview().onDragDropEvent(async (event) => {
+ if (event.payload.type !== "drop") return;
+ for (const path of event.payload.paths) {
+ try {
+ const content = await invoke("read_text_file", { path });
+ cb(content);
+ } catch (e) {
+ console.warn("file drop rejected", path, e);
+ }
+ }
+ });
+ return;
+ }
+ window.addEventListener("dragover", (e) => e.preventDefault());
+ window.addEventListener("drop", async (e) => {
+ e.preventDefault();
+ for (const f of e.dataTransfer?.files ?? []) {
+ if (/\.(numi|txt|md)$/i.test(f.name)) cb(await f.text());
+ }
+ });
+}
diff --git a/src/ui/app.css b/src/ui/app.css
new file mode 100644
index 0000000..9ef8d95
--- /dev/null
+++ b/src/ui/app.css
@@ -0,0 +1,399 @@
+/* Summarum UI styles. */
+:root {
+ --font: "Segoe UI", system-ui, sans-serif;
+ --mono: "Cascadia Code", "Consolas", monospace;
+ --editor-font-size: 15px;
+}
+
+[data-theme="light"] {
+ color-scheme: light;
+ --bg: #ffffff;
+ --bg-soft: #f5f5f4;
+ --fg: #3d3d3d;
+ --fg-muted: #9b9b97;
+ --accent: #d57d2c;
+ --result: #d57d2c;
+ --number: #2a72b5;
+ --unit: #3f9b56;
+ --operator: #777770;
+ --comment: #b0b0aa;
+ --header-bg: #f0efec;
+ --border: #e4e4e0;
+ --sel: #cfe3f7;
+ --caret: #d57d2c;
+ --toolbar-bg: #fafaf8;
+ --hover: #ececea;
+}
+
+[data-theme="dark"] {
+ color-scheme: dark;
+ --bg: #1e1f22;
+ --bg-soft: #26272b;
+ --fg: #d6d6d1;
+ --fg-muted: #6f7076;
+ --accent: #e8923c;
+ --result: #e8923c;
+ --number: #6cb2f0;
+ --unit: #6fc784;
+ --operator: #9a9aa0;
+ --comment: #5c5d63;
+ --header-bg: #2b2c31;
+ --border: #333438;
+ --sel: #2d4a68;
+ --caret: #e8923c;
+ --toolbar-bg: #232428;
+ --hover: #2f3035;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; height: 100%; }
+body {
+ font-family: var(--font);
+ background: var(--bg);
+ color: var(--fg);
+ overflow: hidden;
+ user-select: none;
+}
+
+#app { display: flex; height: 100vh; }
+
+/* ---------- sidebar */
+#sidebar {
+ width: 180px;
+ min-width: 180px;
+ background: var(--bg-soft);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 6px;
+}
+#sidebar.hidden { display: none; }
+#doc-list { flex: 1; overflow-y: auto; }
+.doc-item {
+ padding: 7px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.doc-item:hover { background: var(--hover); }
+.doc-item.active { background: var(--sel); }
+.doc-item .del {
+ margin-left: auto;
+ opacity: 0;
+ color: var(--fg-muted);
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 12px;
+ border-radius: 4px;
+ padding: 2px 6px;
+}
+.doc-item:hover .del { opacity: 1; }
+.doc-item .del.confirm {
+ opacity: 1;
+ background: #d63a3a;
+ color: #fff;
+}
+#new-doc {
+ border: 1px dashed var(--border);
+ background: none;
+ color: var(--fg-muted);
+ border-radius: 6px;
+ padding: 6px;
+ cursor: pointer;
+ font-size: 15px;
+}
+#new-doc:hover { background: var(--hover); }
+
+/* ---------- main */
+#content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+
+#titlebar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ height: 36px;
+ padding: 0 0 0 6px;
+ background: var(--toolbar-bg);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+#titlebar button {
+ border: none;
+ background: none;
+ color: var(--fg-muted);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 4px 9px;
+ border-radius: 5px;
+}
+#titlebar button:hover { background: var(--hover); color: var(--fg); }
+#doc-title {
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--fg);
+ font-family: var(--font);
+ font-size: 13px;
+ padding: 4px 8px;
+ border-radius: 5px;
+ width: 170px;
+ outline: none;
+}
+#doc-title:hover { background: var(--hover); }
+#doc-title:focus { background: var(--bg); border-color: var(--border); }
+.spacer { flex: 1; height: 100%; }
+#rates-status { font-size: 11px; color: var(--fg-muted); }
+
+#win-controls { display: flex; height: 100%; margin-left: 2px; }
+#win-controls button {
+ height: 100%;
+ width: 42px;
+ border-radius: 0;
+ font-size: 12px;
+ padding: 0;
+}
+#win-close:hover { background: #d63a3a !important; color: #fff !important; }
+
+/* ---------- status bar (total of all results on the sheet) */
+#statusbar {
+ display: flex;
+ align-items: center;
+ height: 28px;
+ padding: 0 14px;
+ background: var(--toolbar-bg);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+ gap: 8px;
+}
+#rates-info {
+ font-size: 11.5px;
+ color: var(--fg-muted);
+ cursor: pointer;
+ white-space: nowrap;
+}
+#rates-info:hover { color: var(--fg); }
+#rates-info.spin { opacity: 0.5; pointer-events: none; }
+#total-line {
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--result);
+ cursor: pointer;
+}
+#total-line:hover { text-decoration: underline; }
+#total-line .total-label { color: var(--fg-muted); font-family: var(--font); margin-right: 8px; }
+
+/* ---------- editor + results */
+#editor-wrap { position: relative; flex: 1; overflow: hidden; --results-width: 42%; }
+#editor { height: 100%; }
+
+/* draggable boundary of the results column */
+#col-divider {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: var(--results-width);
+ width: 7px;
+ margin-right: -3px;
+ cursor: col-resize;
+ z-index: 10;
+}
+#col-divider::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 3px;
+ width: 1px;
+ background: var(--border);
+}
+#col-divider:hover::after, #col-divider.dragging::after {
+ width: 2px;
+ background: var(--accent);
+}
+
+.cm-editor { height: 100%; background: var(--bg); }
+.cm-editor.cm-focused { outline: none; }
+.cm-scroller {
+ font-family: var(--mono);
+ font-size: var(--editor-font-size);
+ line-height: 1.7;
+ padding: 12px 0;
+}
+/* layout paddings for .cm-content live in the CM theme (editor.ts) —
+ runtime-injected CM base styles would override them here */
+.cm-content {
+ caret-color: var(--caret);
+ color: var(--fg);
+}
+.cm-line { padding: 0; }
+.cm-cursor { border-left-color: var(--caret); }
+.cm-selectionBackground, .cm-content ::selection { background: var(--sel) !important; }
+.cm-activeLine { background: transparent; }
+
+/* token colors */
+.tok-number { color: var(--number); }
+.tok-unit { color: var(--unit); }
+.tok-op { color: var(--operator); }
+.tok-comment { color: var(--comment); font-style: italic; }
+.tok-header { color: var(--fg); font-weight: 600; }
+.tok-var { color: var(--accent); }
+.tok-var-active {
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
+ border-radius: 3px;
+}
+
+/* results overlay */
+#results {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: var(--results-width);
+ height: 100%;
+ pointer-events: none;
+ overflow: hidden;
+}
+.result-line {
+ position: absolute;
+ right: 14px;
+ max-width: calc(100% - 14px);
+ font-family: var(--mono);
+ font-size: var(--editor-font-size);
+ line-height: 1.7;
+ color: var(--result);
+ background: var(--bg);
+ padding: 0 6px;
+ border-radius: 4px;
+ cursor: pointer;
+ pointer-events: auto;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.result-line:hover { text-decoration: underline; }
+
+/* ---------- settings */
+#settings-panel {
+ position: absolute;
+ top: 36px;
+ right: 8px;
+ width: 280px;
+ background: var(--bg-soft);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
+ padding: 14px;
+ z-index: 50;
+}
+#settings-panel.hidden { display: none; }
+#settings-panel h2 { margin: 0 0 10px; font-size: 14px; }
+#settings-panel label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12.5px;
+ margin: 8px 0;
+ gap: 8px;
+}
+#settings-panel label.row { justify-content: flex-start; }
+#settings-panel input[type="number"], #settings-panel input[type="text"], #settings-panel select {
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ padding: 4px 6px;
+ font-size: 12.5px;
+ width: 110px;
+}
+.path-btn {
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ padding: 4px 8px;
+ font-size: 11.5px;
+ width: 130px;
+ cursor: pointer;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left;
+}
+.path-btn:hover { background: var(--hover); }
+
+.settings-footer { display: flex; justify-content: space-between; gap: 6px; margin-top: 12px; }
+.settings-footer button {
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--fg);
+ border-radius: 6px;
+ padding: 5px 12px;
+ cursor: pointer;
+ font-size: 12.5px;
+}
+.settings-footer button:hover { background: var(--hover); }
+
+/* ---------- toast */
+#toast {
+ position: fixed;
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--fg);
+ color: var(--bg);
+ font-size: 12.5px;
+ padding: 6px 14px;
+ border-radius: 14px;
+ opacity: 0.92;
+ z-index: 100;
+ transition: opacity 0.2s;
+}
+#toast.hidden { opacity: 0; pointer-events: none; }
+
+/* ---------- modal (data folder conflict etc.) */
+#modal {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+}
+#modal.hidden { display: none; }
+.modal-box {
+ background: var(--bg-soft);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 16px;
+ max-width: 320px;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
+}
+.modal-box p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; }
+.modal-btns { display: flex; gap: 8px; justify-content: flex-end; }
+.modal-btns button {
+ border: 1px solid var(--border);
+ background: var(--bg);
+ color: var(--fg);
+ border-radius: 6px;
+ padding: 5px 12px;
+ cursor: pointer;
+ font-size: 12.5px;
+}
+.modal-btns button:hover { background: var(--hover); }
+.modal-btns button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
+
+/* autocomplete */
+.cm-tooltip.cm-tooltip-autocomplete {
+ background: var(--bg-soft);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
+.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
diff --git a/src/ui/editor.ts b/src/ui/editor.ts
new file mode 100644
index 0000000..035563a
--- /dev/null
+++ b/src/ui/editor.ts
@@ -0,0 +1,279 @@
+// CodeMirror editor wired to the engine: evaluates on change, highlights
+// engine tokens, renders the results overlay, autocompletes phrases.
+import { EditorView, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
+import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
+import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
+import { autocompletion, CompletionContext, CompletionResult, completionKeymap } from "@codemirror/autocomplete";
+import { SumEngine, LineResult } from "../engine";
+
+export interface EditorCallbacks {
+ onChange(text: string): void;
+ onCopy(text: string): void;
+ onResults(results: LineResult[]): void;
+ /** 0-based inclusive line range of the selection, null when collapsed */
+ onSelection(range: [number, number] | null): void;
+}
+
+const setResults = StateEffect.define();
+
+const resultsField = StateField.define({
+ create: () => [],
+ update(value, tr) {
+ for (const e of tr.effects) if (e.is(setResults)) value = e.value;
+ return value;
+ },
+});
+
+function buildDecorations(state: EditorState): DecorationSet {
+ const results = state.field(resultsField);
+ const builder = new RangeSetBuilder();
+ const doc = state.doc;
+ for (let i = 0; i < Math.min(results.length, doc.lines); i++) {
+ const line = doc.line(i + 1);
+ const r = results[i];
+ if (r.kind === "header") {
+ builder.add(line.from, line.from, Decoration.line({ class: "tok-header" }));
+ continue;
+ }
+ const marks: { from: number; to: number; cls: string }[] = [];
+ for (const tk of r.tokens) {
+ let cls: string | null = null;
+ switch (tk.t) {
+ case "num": case "const": case "scale": cls = "tok-number"; break;
+ case "unit": case "currency": case "repr": case "date": cls = "tok-unit"; break;
+ case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
+ case "func": case "agg": cls = "tok-var"; break;
+ default: cls = null;
+ }
+ if (cls && tk.end > tk.start && line.from + tk.end <= line.to) {
+ marks.push({ from: line.from + tk.start, to: line.from + tk.end, cls });
+ }
+ }
+ if (r.commentStart !== null) {
+ marks.push({ from: line.from + r.commentStart, to: line.to, cls: "tok-comment" });
+ }
+ marks.sort((a, b) => a.from - b.from);
+ let last = line.from;
+ for (const m of marks) {
+ if (m.from < last) continue; // overlapping (comment vs tokens) — first wins
+ builder.add(m.from, m.to, Decoration.mark({ class: m.cls }));
+ last = m.to;
+ }
+ }
+ return builder.finish();
+}
+
+const highlightField = StateField.define({
+ create: buildDecorations,
+ update(_value, tr) {
+ return buildDecorations(tr.state);
+ },
+ provide: (f) => EditorView.decorations.from(f),
+});
+
+/** Highlights every occurrence of the variable under the cursor. */
+function buildVarHighlight(state: EditorState): DecorationSet {
+ const builder = new RangeSetBuilder();
+ const sel = state.selection.main;
+ if (!sel.empty) return builder.finish();
+ const text = state.doc.toString();
+ // expand a word around the cursor
+ const isWordCh = (ch: string) => /[\p{L}\d_]/u.test(ch);
+ let a = sel.head;
+ let b = sel.head;
+ while (a > 0 && isWordCh(text[a - 1])) a--;
+ while (b < text.length && isWordCh(text[b])) b++;
+ if (a === b) return builder.finish();
+ const word = text.slice(a, b);
+ if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
+ // only highlight if the word is actually assigned somewhere in the document
+ const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish();
+ const re = new RegExp(`(?({
+ create: buildVarHighlight,
+ update(_value, tr) {
+ return buildVarHighlight(tr.state);
+ },
+ provide: (f) => EditorView.decorations.from(f),
+});
+
+export class SumEditor {
+ view: EditorView;
+ private resultsEl: HTMLElement;
+ private results: LineResult[] = [];
+
+ constructor(
+ parent: HTMLElement,
+ resultsEl: HTMLElement,
+ private engine: SumEngine,
+ private cb: EditorCallbacks,
+ initialText: string,
+ ) {
+ this.resultsEl = resultsEl;
+
+ const completionSource = (ctx: CompletionContext): CompletionResult | null => {
+ const word = ctx.matchBefore(/[\p{L}_]+$/u);
+ if (!word || (word.from === word.to && !ctx.explicit)) return null;
+ if (word.to - word.from < 2 && !ctx.explicit) return null;
+ const options = this.engine.completions().map((c) => ({
+ label: c.label,
+ type: c.type === "function" ? "function" : c.type === "keyword" ? "keyword" : "constant",
+ detail: c.detail,
+ }));
+ // document variables
+ const text = ctx.state.doc.toString();
+ for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
+ options.push({ label: m[1], type: "variable", detail: undefined as unknown as string });
+ }
+ return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
+ };
+
+ this.view = new EditorView({
+ parent,
+ state: EditorState.create({
+ doc: initialText,
+ extensions: [
+ history(),
+ keymap.of([...defaultKeymap, ...historyKeymap, ...completionKeymap]),
+ autocompletion({ override: [completionSource], activateOnTyping: true }),
+ resultsField,
+ highlightField,
+ varHighlightField,
+ EditorView.lineWrapping,
+ drawSelection({ cursorBlinkRate: 1000 }),
+ // CM injects its base styles at runtime after app.css, so layout
+ // and cursor styling must live in a theme to take precedence
+ EditorView.theme({
+ ".cm-content": {
+ // --results-width lives on #editor-wrap; the divider drags it
+ paddingRight: "var(--results-width, 42%)",
+ paddingLeft: "16px",
+ overflowWrap: "anywhere",
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeft: "2px solid var(--caret)",
+ },
+ }),
+ EditorView.updateListener.of((u: ViewUpdate) => {
+ if (u.docChanged) {
+ const text = u.state.doc.toString();
+ this.evaluate(text);
+ this.cb.onChange(text);
+ }
+ if (u.geometryChanged || u.viewportChanged || u.docChanged) {
+ this.renderResults();
+ }
+ if (u.selectionSet || u.docChanged) {
+ const sel = u.state.selection.main;
+ if (sel.empty) {
+ this.cb.onSelection(null);
+ } else {
+ const from = u.state.doc.lineAt(sel.from).number - 1;
+ const to = u.state.doc.lineAt(sel.to).number - 1;
+ this.cb.onSelection([from, to]);
+ }
+ }
+ }),
+ ],
+ }),
+ });
+
+ this.evaluate(initialText);
+ requestAnimationFrame(() => this.renderResults());
+ this.view.scrollDOM.addEventListener("scroll", () => this.renderResults());
+
+ // copying whole lines also copies their results: "5+5 = 10";
+ // a partial single-line selection copies as usual
+ this.view.dom.addEventListener("copy", (e) => {
+ const state = this.view.state;
+ const sel = state.selection.main;
+ const doc = state.doc;
+ const lineFrom = doc.lineAt(sel.from);
+ const lineTo = doc.lineAt(sel.to);
+ const multiLine = lineFrom.number !== lineTo.number;
+ const coversLine = sel.from <= lineFrom.from && sel.to >= lineTo.to && !sel.empty;
+ if (!sel.empty && !multiLine && !coversLine) return;
+ const lines: string[] = [];
+ for (let n = lineFrom.number; n <= lineTo.number; n++) {
+ const line = doc.line(n);
+ const res = this.results[n - 1]?.text;
+ lines.push(res ? `${line.text} = ${res}` : line.text);
+ }
+ e.clipboardData?.setData("text/plain", lines.join("\n"));
+ e.preventDefault();
+ });
+
+ this.resultsEl.addEventListener("click", (e) => {
+ const el = (e.target as HTMLElement).closest(".result-line") as HTMLElement | null;
+ if (el?.dataset.value) this.cb.onCopy(el.dataset.value);
+ });
+ }
+
+ setText(text: string): void {
+ this.view.dispatch({
+ changes: { from: 0, to: this.view.state.doc.length, insert: text },
+ });
+ }
+
+ getText(): string {
+ return this.view.state.doc.toString();
+ }
+
+ /** whole sheet with results appended: for Ctrl+Shift+C export */
+ getSheetWithResults(): string {
+ const doc = this.view.state.doc;
+ const lines: string[] = [];
+ for (let n = 1; n <= doc.lines; n++) {
+ const res = this.results[n - 1]?.text;
+ const text = doc.line(n).text;
+ lines.push(res ? `${text} = ${res}` : text);
+ }
+ return lines.join("\n");
+ }
+
+ /** re-evaluate with current engine state (settings/rates changed) */
+ refresh(): void {
+ this.evaluate(this.getText());
+ this.renderResults();
+ }
+
+ focus(): void {
+ this.view.focus();
+ }
+
+ private evaluate(text: string): void {
+ this.results = this.engine.evaluateDocument(text);
+ this.view.dispatch({ effects: setResults.of(this.results) });
+ this.cb.onResults(this.results);
+ }
+
+ private renderResults(): void {
+ const frag = document.createDocumentFragment();
+ const doc = this.view.state.doc;
+ // documentTop is in screen coordinates; place results relative to the overlay
+ const overlayTop = this.resultsEl.getBoundingClientRect().top;
+ const docTop = this.view.documentTop;
+ for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
+ const r = this.results[i];
+ if (!r.text) continue;
+ const line = doc.line(i + 1);
+ const block = this.view.lineBlockAt(line.from);
+ const top = docTop + block.top - overlayTop;
+ if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
+ const el = document.createElement("div");
+ el.className = "result-line";
+ el.textContent = r.text;
+ el.dataset.value = r.text;
+ el.style.top = `${top}px`;
+ frag.appendChild(el);
+ }
+ this.resultsEl.replaceChildren(frag);
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..10a4dc0
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "useDefineForClassFields": true,
+ "allowImportingTsExtensions": true,
+ "noEmit": true
+ },
+ "include": ["src", "scripts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..9aa0e07
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ clearScreen: false,
+ server: {
+ port: 1420,
+ strictPort: true,
+ },
+ build: {
+ target: "es2022",
+ outDir: "dist",
+ },
+});