9 KiB
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)
updater.ts checks tauri-plugin-updater, installs + relaunches
src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance) with
cache, historical rates (frankfurter.app, permanent
per-date cache), backups, data-folder migration,
file drops, plugin registration (autostart,
global-shortcut, single-instance, opener, dialog,
updater, process)
How a line becomes a result
- Lexer splits
"5 inches in cm"into lexemes:5,inches,in,cm. The same lexer segments vocabulary phrases, so matching is consistent. - Registry holds every phrase the engine knows, keyed by first lexeme.
Longest match wins; case-sensitive entries (
mvsM,kB) are tried before case-insensitive ones, and an ambiguous lowercase form (mmvsMm) simply doesn't get a case-insensitive fallback. - Tokenizer walks the lexemes, asks the registry, and emits typed tokens.
A couple of context hacks live here, e.g.
inright after a number at the end of a line means inches (6 ft 3 in). - Parser is a Pratt parser that tolerates noise: unknown words are
dropped before parsing (that's why
spent 20 on pizza plus 5works) — except words right after a conversion operator, which may name a timezone. Precedence, loosest to tightest: line-level conversions (in cmapplies to the merged line, so2 hours 30 minutes in minutesworks) → bitwise → additive → multiplicative (incl.20% of x) → unary → postfix (units,%,!,^) → primary. - 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 forsum/avg/prev. - 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
(
mmeter /Mmillion,kthousand /Kkelvin). 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 wordas. and/orare dual-purpose:andadds (5 and 3= 8),oris bitwise.of/off/ononly act on a percent left-hand side; otherwise they're prose. The sameontoken doubles as the historical-rate date separator (in EUR on 2024-01-01) —parseSeqchecks forpctop(on)+datelitright after a currency conv target and consumes both when present.- Currency rates are "units per USD"; a currency Unit's ratio is computed at
evaluation time from the current rate map, so re-setting rates re-prices
everything on the next evaluation. Historical rates live in a separate
historicalRates: Map<date, Map<code, number>>onSumEngineand are passed throughEvalCtxtoevalConv. - Live market data (stocks, commodities via Yahoo Finance) is merged into the
same rate map as exchange rates via
applyAllRates()inmain.tsto avoid onesetRates()call overwriting the other. - Goal seek (
? * 1.2 = 1000) tokenizes?as{ t: "unknown" }, parsed into{ k: "unknown" }nodes.parseLinedetects the presence ofunknownassignand builds a{ k: "goalseek", lhs, rhs }node. The solver tries a linear probe (f(0), f(1) → exact root for affine expressions) and falls back to bisection for nonlinear ones.
ChartValue(kind: "chart") is returned byevalAgg("chart", …)and bypassesformatValue— the results overlay renders an SVG sparkline instead of a text string.- 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 anEditorView.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 readsdata-value. - Engine evaluation is synchronous on every keystroke — a full sheet parse is well under a millisecond, so there's no debounce or worker.
- Search-all-sheets (
Ctrl+F) is a plain line scan overdata.contentsinmain.ts, not a CodeMirror search extension — it has to cross documents, so it never touches the active editor until a result is clicked (editor.goToLine).
Storage
settings.jsonalways 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.numifiles underbackups/deleted/and are pruned by age. Saving is debounced at 400 ms.
Auto-update
updater.tscallstauri-plugin-updater'scheck()on boot; if a signed update is available the user is prompted, thendownloadAndInstall()+tauri-plugin-process'srelaunch().- The update endpoint is
latest.jsonpublished alongside each GitHub Release.release.ymlsigns both architectures with a minisign keypair: the public half is embedded intauri.conf.json; the private half + its password live only asTAURI_SIGNING_PRIVATE_KEY(_PASSWORD)repo secrets (write-only — there is no durable copy in the repo). Losing that key means already-installed copies can never trust a future signed release again.
Storage (cache files)
All files below live in %APPDATA%/app.summarum.calc (or the user-chosen folder):
| File | Contents | TTL |
|---|---|---|
settings.json |
app settings | persisted |
documents.json |
all sheets | persisted (400 ms debounce) |
rates.json |
live exchange rates | 1 hour |
market.json |
stock/commodity prices | 15 minutes |
rates-YYYY-MM-DD.json |
historical ECB rates for one date | permanent |
backups/documents-YYYY-MM-DD.json |
daily snapshots | 14 days |
backups/deleted/*.numi |
soft-deleted sheets | configurable |
Tests
npm test runs 113 vitest cases over the engine (src/engine/*.test.ts):
every expression class, both languages, deterministic injected rates,
goal seek, historical rates (injected), plus a regression suite covering
all known-fixed bugs. UI is exercised manually; the engine is where the
complexity lives.