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:
Horizontal42 2026-06-10 22:19:24 +03:00
commit 0cef414e9a
44 changed files with 14354 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
*.log

119
ARCHITECTURE.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

37
package.json Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

26
src-tauri/Cargo.toml Normal file
View 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
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}
}

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

72
src/engine/vocab.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
export default defineConfig({
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
build: {
target: "es2022",
outDir: "dist",
},
});