mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 15:42:01 +03:00
feat: Summarum — natural language calculator notebook for Windows
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.
This commit is contained in:
commit
0cef414e9a
44 changed files with 14354 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
|
*.log
|
||||||
119
ARCHITECTURE.md
Normal file
119
ARCHITECTURE.md
Normal file
|
|
@ -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.
|
||||||
6
LICENSE
Normal file
6
LICENSE
Normal file
|
|
@ -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.]
|
||||||
136
README.md
Normal file
136
README.md
Normal file
|
|
@ -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)
|
||||||
98
index.html
Normal file
98
index.html
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Summarum</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<aside id="sidebar" class="hidden">
|
||||||
|
<div id="doc-list"></div>
|
||||||
|
<button id="new-doc" title="New document">+</button>
|
||||||
|
</aside>
|
||||||
|
<main id="content">
|
||||||
|
<div id="titlebar" data-tauri-drag-region>
|
||||||
|
<button id="toggle-sidebar" title="Documents (Ctrl+B)">☰</button>
|
||||||
|
<input id="doc-title" spellcheck="false" autocomplete="off" />
|
||||||
|
<span class="spacer" data-tauri-drag-region></span>
|
||||||
|
<button id="open-settings" title="Settings">⚙</button>
|
||||||
|
<div id="win-controls">
|
||||||
|
<button id="win-min" title="Minimize">─</button>
|
||||||
|
<button id="win-close" title="Hide to tray">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="editor-wrap">
|
||||||
|
<div id="editor"></div>
|
||||||
|
<div id="results"></div>
|
||||||
|
<div id="col-divider" title="Drag to resize"></div>
|
||||||
|
</div>
|
||||||
|
<div id="statusbar">
|
||||||
|
<span id="rates-info" title=""></span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span id="total-line" title=""></span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div id="settings-panel" class="hidden">
|
||||||
|
<h2 data-i18n="settings">Settings</h2>
|
||||||
|
<label><span data-i18n="theme">Theme</span>
|
||||||
|
<select id="set-theme">
|
||||||
|
<option value="system" data-i18n="system">System</option>
|
||||||
|
<option value="light" data-i18n="light">Light</option>
|
||||||
|
<option value="dark" data-i18n="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="precision">Decimal places</span>
|
||||||
|
<input id="set-precision" type="number" min="0" max="15" />
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="groupsep">Thousands separator</span>
|
||||||
|
<select id="set-groupsep">
|
||||||
|
<option value=",">1,000</option>
|
||||||
|
<option value=" ">1 000</option>
|
||||||
|
<option value="">1000</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="language">Language</span>
|
||||||
|
<select id="set-lang">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="hotkey">Global hotkey</span>
|
||||||
|
<input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" />
|
||||||
|
</label>
|
||||||
|
<label class="row"><input id="set-autostart" type="checkbox" />
|
||||||
|
<span data-i18n="autostart">Launch at startup</span>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="fontsize">Font size</span>
|
||||||
|
<input id="set-fontsize" type="number" min="10" max="32" />
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="resultswidth">Results column</span>
|
||||||
|
<input id="set-resultswidth" type="range" min="20" max="60" step="1" />
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="bindays">Bin retention (days)</span>
|
||||||
|
<input id="set-bindays" type="number" min="1" max="365" step="1" />
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="datadir">Data folder</span>
|
||||||
|
<button id="set-datadir" class="path-btn" title=""></button>
|
||||||
|
</label>
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button id="open-extensions" data-i18n="extensions">Extensions folder</button>
|
||||||
|
<button id="open-backups" data-i18n="backups">Backups</button>
|
||||||
|
<button id="close-settings" data-i18n="done">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="toast" class="hidden"></div>
|
||||||
|
<div id="modal" class="hidden">
|
||||||
|
<div class="modal-box">
|
||||||
|
<p id="modal-msg"></p>
|
||||||
|
<div class="modal-btns">
|
||||||
|
<button id="modal-a"></button>
|
||||||
|
<button id="modal-b"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2064
package-lock.json
generated
Normal file
2064
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
package.json
Normal file
37
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
156
scripts/gen-icon.ts
Normal file
156
scripts/gen-icon.ts
Normal file
|
|
@ -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<number, Buffer>();
|
||||||
|
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)");
|
||||||
5745
src-tauri/Cargo.lock
generated
Normal file
5745
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
src-tauri/Cargo.toml
Normal file
26
src-tauri/Cargo.toml
Normal file
|
|
@ -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
|
||||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
25
src-tauri/capabilities/default.json
Normal file
25
src-tauri/capabilities/default.json
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
src-tauri/extension-sample.js
Normal file
21
src-tauri/extension-sample.js
Normal file
|
|
@ -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) };
|
||||||
|
// });
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 971 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 B |
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
475
src-tauri/src/main.rs
Normal file
475
src-tauri/src/main.rs
Normal file
|
|
@ -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<String>) -> 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<Option<String>, 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<String, serde_json::Value>,
|
||||||
|
#[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<RatesCache> {
|
||||||
|
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<RatesPayload, String> {
|
||||||
|
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::<serde_json::Value>().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<RatesPayload, String> {
|
||||||
|
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<String>) -> Result<Option<String>, 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<String>, 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<String>, 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<PathBuf> = 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<String>, 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<String>) -> 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<String>, 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<String, String> {
|
||||||
|
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<ExtensionScript> {
|
||||||
|
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<String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
54
src-tauri/tauri.conf.json
Normal file
54
src-tauri/tauri.conf.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/engine/datetime.ts
Normal file
109
src/engine/datetime.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
||||||
|
|
||||||
|
const Z: Record<string, string> = {
|
||||||
|
// ---- 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);
|
||||||
|
}
|
||||||
144
src/engine/edge.test.ts
Normal file
144
src/engine/edge.test.ts
Normal file
|
|
@ -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(/^\$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
241
src/engine/engine.test.ts
Normal file
241
src/engine/engine.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
466
src/engine/evaluator.ts
Normal file
466
src/engine/evaluator.ts
Normal file
|
|
@ -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<string, Value>;
|
||||||
|
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<Value, { kind: "date" }>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
98
src/engine/extraunits.ts
Normal file
98
src/engine/extraunits.ts
Normal file
|
|
@ -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 },
|
||||||
|
];
|
||||||
116
src/engine/formatter.ts
Normal file
116
src/engine/formatter.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
217
src/engine/index.ts
Normal file
217
src/engine/index.ts
Normal file
|
|
@ -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<string, number> = {};
|
||||||
|
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<string, Value>();
|
||||||
|
|
||||||
|
constructor(settings: Partial<EngineSettings> = {}) {
|
||||||
|
this.settings = { ...defaultSettings, ...settings };
|
||||||
|
this.reg = buildRegistry();
|
||||||
|
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRates(rates: Record<string, number | string>): 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<EngineSettings>): 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<string, Value>(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";
|
||||||
79
src/engine/lexer.ts
Normal file
79
src/engine/lexer.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
389
src/engine/parser.ts
Normal file
389
src/engine/parser.ts
Normal file
|
|
@ -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<string>): 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<string>) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/engine/rates-snapshot.json
Normal file
33
src/engine/rates-snapshot.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
394
src/engine/registry.ts
Normal file
394
src/engine/registry.ts
Normal file
|
|
@ -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<string, PhraseEntry[]>();
|
||||||
|
unitsById = new Map<string, Unit>();
|
||||||
|
currencyCodes = new Set<string>();
|
||||||
|
currencyFormat = new Map<string, string>();
|
||||||
|
/** code -> currency units per 1 USD */
|
||||||
|
rates = new Map<string, Decimal>();
|
||||||
|
customFuncs = new Map<string, ExtensionFunc>();
|
||||||
|
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<string, number | string>): 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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
128
src/engine/tokenizer.ts
Normal file
128
src/engine/tokenizer.ts
Normal file
|
|
@ -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<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "pow"> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
90
src/engine/types.ts
Normal file
90
src/engine/types.ts
Normal file
|
|
@ -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: ".",
|
||||||
|
};
|
||||||
135
src/engine/unitdata.ts
Normal file
135
src/engine/unitdata.ts
Normal file
|
|
@ -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 `<id>.variants` / `<id>.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<string, string> = {
|
||||||
|
thousand: "1e3",
|
||||||
|
million: "1e6",
|
||||||
|
billion: "1e9",
|
||||||
|
};
|
||||||
1015
src/engine/vocab-data.ts
Normal file
1015
src/engine/vocab-data.ts
Normal file
File diff suppressed because it is too large
Load diff
72
src/engine/vocab.ts
Normal file
72
src/engine/vocab.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
export type VocabLocale = Record<string, VocabCategory>;
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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];
|
||||||
|
}
|
||||||
29
src/extensions.ts
Normal file
29
src/extensions.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/i18n.ts
Normal file
101
src/i18n.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/** UI localization (en/ru). The engine itself understands both languages at once. */
|
||||||
|
|
||||||
|
const dict: Record<string, Record<string, string>> = {
|
||||||
|
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<HTMLElement>("[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";
|
||||||
|
}
|
||||||
576
src/main.ts
Normal file
576
src/main.ts
Normal file
|
|
@ -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 $ = <T extends HTMLElement>(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 = $<HTMLInputElement>("#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<typeof setTimeout> | 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();
|
||||||
|
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSettingsUI(): void {
|
||||||
|
const themeSel = $<HTMLSelectElement>("#set-theme");
|
||||||
|
const precision = $<HTMLInputElement>("#set-precision");
|
||||||
|
const groupSep = $<HTMLSelectElement>("#set-groupsep");
|
||||||
|
const langSel = $<HTMLSelectElement>("#set-lang");
|
||||||
|
const hotkey = $<HTMLInputElement>("#set-hotkey");
|
||||||
|
const autostart = $<HTMLInputElement>("#set-autostart");
|
||||||
|
const fontSize = $<HTMLInputElement>("#set-fontsize");
|
||||||
|
const resultsWidth = $<HTMLInputElement>("#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 = $<HTMLInputElement>("#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 = $<HTMLButtonElement>("#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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<typeof setTimeout> | 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<void> {
|
||||||
|
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 = $<HTMLInputElement>("#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 = "";
|
||||||
|
$<HTMLInputElement>("#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();
|
||||||
241
src/storage.ts
Normal file
241
src/storage.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
return invoke<T>(cmd, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- settings
|
||||||
|
|
||||||
|
export async function loadSettings(): Promise<SettingsData> {
|
||||||
|
try {
|
||||||
|
const raw = isTauri()
|
||||||
|
? await invoke<string | null>("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<void> {
|
||||||
|
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<AppData | null> {
|
||||||
|
try {
|
||||||
|
const raw = isTauri()
|
||||||
|
? await invoke<string | null>("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<typeof setTimeout> | 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function chooseFolder(): Promise<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
if (!isTauri()) return false;
|
||||||
|
return invoke<boolean>("data_dir_has_documents", { dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MigrateStrategy = "move" | "overwrite" | "use_existing";
|
||||||
|
|
||||||
|
export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise<void> {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- rates
|
||||||
|
|
||||||
|
export interface RatesPayload {
|
||||||
|
date: string;
|
||||||
|
rates: Record<string, number>;
|
||||||
|
/** unix seconds when the rates were actually fetched */
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
||||||
|
try {
|
||||||
|
if (isTauri()) {
|
||||||
|
return await invoke<RatesPayload>("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<void> {
|
||||||
|
if (isTauri()) await invoke("open_extensions_folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- .numi file association
|
||||||
|
|
||||||
|
export async function getLaunchFile(): Promise<string | null> {
|
||||||
|
if (!isTauri()) return null;
|
||||||
|
try {
|
||||||
|
return await invoke<string | null>("get_launch_file");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onOpenFile(cb: (content: string) => void): Promise<void> {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
|
await listen<string>("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<void> {
|
||||||
|
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<string>("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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
399
src/ui/app.css
Normal file
399
src/ui/app.css
Normal file
|
|
@ -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); }
|
||||||
279
src/ui/editor.ts
Normal file
279
src/ui/editor.ts
Normal file
|
|
@ -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<LineResult[]>();
|
||||||
|
|
||||||
|
const resultsField = StateField.define<LineResult[]>({
|
||||||
|
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<Decoration>();
|
||||||
|
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<DecorationSet>({
|
||||||
|
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<Decoration>();
|
||||||
|
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(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
|
||||||
|
for (const m of text.matchAll(re)) {
|
||||||
|
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
||||||
|
}
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
const varHighlightField = StateField.define<DecorationSet>({
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: "es2022",
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue