Compare commits

..

No commits in common. "main" and "v1.0.1" have entirely different histories.
main ... v1.0.1

64 changed files with 1044 additions and 7642 deletions

View file

@ -14,40 +14,18 @@ jobs:
fail-fast: false fail-fast: false
# tauri-action's find-or-create-release check races across parallel # tauri-action's find-or-create-release check races across parallel
# matrix jobs and can create two separate release objects for the # matrix jobs and can create two separate release objects for the
# same tag, each missing the other's assets — force serial builds. # same tag, each missing the other's assets — force serial builds
# Every platform added below shares that release object, so they all
# have to stay in this one serialized matrix.
max-parallel: 1 max-parallel: 1
matrix: matrix:
include: include:
- os: windows-latest - target: x86_64-pc-windows-msvc
target: x86_64-pc-windows-msvc
arch: x64 arch: x64
- os: windows-latest - target: aarch64-pc-windows-msvc
target: aarch64-pc-windows-msvc
arch: arm64 arch: arm64
# 22.04 is the oldest runner with webkit2gtk-4.1 (what Tauri 2 runs-on: windows-latest
# needs); building on it keeps the glibc floor as low as possible
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
arch: x64
- os: macos-latest
target: aarch64-apple-darwin
arch: arm64
- os: macos-latest
target: x86_64-apple-darwin
arch: x64
runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file \
libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev \
libayatana-appindicator3-dev patchelf
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
@ -64,19 +42,9 @@ jobs:
- run: npm ci - run: npm ci
# TAURI_SIGNING_* below is the updater's minisign keypair, not OS code
# signing — there is no Apple certificate, so macOS builds are only
# ad-hoc signed (bundle.macOS.signingIdentity "-") and Gatekeeper still
# asks the user to allow them on first launch.
#
# RELEASE_TOKEN (fine-grained PAT, Contents: read/write) stands in for
# the built-in GITHUB_TOKEN: the Actions-native token started getting
# "Resource not accessible by integration" on release creation for this
# repo — confirmed not a permissions:/repo-settings/tag-protection issue,
# a personal token hits the same Releases API fine.
- uses: tauri-apps/tauri-action@v0 - uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
@ -95,27 +63,17 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- env: - env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
prev=$(git tag --sort=-v:refname | grep -v "^${{ github.ref_name }}$" | head -1) prev=$(git tag --sort=-v:refname | grep -v "^${{ github.ref_name }}$" | head -1)
# release-checklist version bumps (bump version to X / bump cargo
# version to X / update tauri.conf version to X / sync Cargo.lock
# or package-lock.json with X) are internal bookkeeping, not
# user-facing changes — never worth a changelog line.
noise='^- (bump( cargo)? version to|update tauri\.conf version to|sync (Cargo\.lock|package-lock\.json) with) [0-9]+\.[0-9]+\.[0-9]+$'
filter() {
grep -vE "$noise" \
| sed -E 's/ \(#[0-9]+\)$//' \
| perl -CSD -pe 's/^- \p{Emoji_Presentation}\x{FE0F}?\s*/- /'
}
{ {
echo "## What's new" echo "## What's new"
if [ -n "$prev" ]; then if [ -n "$prev" ]; then
git log "$prev..${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | filter git log "$prev..${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | grep -v "^- bump version"
echo "" echo ""
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}" echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}"
else else
git log "${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | filter git log "${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | grep -v "^- bump version"
fi fi
} > notes.md } > notes.md
gh release edit "${{ github.ref_name }}" --notes-file notes.md --draft=false gh release edit "${{ github.ref_name }}" --notes-file notes.md

5
.gitignore vendored
View file

@ -2,7 +2,6 @@ node_modules/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/gen/ src-tauri/gen/
src-tauri/.cargo/
*.log *.log
# local reference copies of third-party sources — not ours to ship # local reference copies of third-party sources — not ours to ship
@ -11,10 +10,6 @@ numi-master/
# one-off debugging artifacts # one-off debugging artifacts
scripts/smoke-* scripts/smoke-*
test_perf.ts
# AI coding agent scratch/notes directories
.jules/
# updater signing key — private, never publish (see .tauri-signing/README.txt) # updater signing key — private, never publish (see .tauri-signing/README.txt)
.tauri-signing/ .tauri-signing/

View file

@ -1,22 +0,0 @@
when:
- event: [push, manual]
steps:
- name: test-engine
image: node:22-bookworm-slim
commands:
- npm ci
- npx tsc --noEmit
- npx vitest run
- name: build-tauri-linux
image: rust:1-bookworm
commands:
- rustup component add clippy
- apt-get update && apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev libayatana-appindicator3-dev
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- npm ci
- cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
- cargo test --manifest-path src-tauri/Cargo.toml
- CI=true npx tauri build --no-bundle --ci

View file

@ -21,26 +21,18 @@ src/
index.ts SumEngine facade + extension API + sheet totals index.ts SumEngine facade + extension API + sheet totals
ui/ ui/
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
search.ts cross-sheet search overlay (text + result-value queries)
export.ts renders the sheet + results to a PNG (clipboard/file)
sparkline.ts SVG sparkline renderer for `chart` results
app.css themes (light/dark via CSS variables) app.css themes (light/dark via CSS variables)
main.ts app bootstrap: documents, settings, status bar, dnd main.ts app bootstrap: documents, settings, status bar, dnd
workspace.ts cross-sheet references: exports cache, cycle detection
storage.ts Tauri commands / localStorage fallback storage.ts Tauri commands / localStorage fallback
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged extensions.ts runs user .js files against the engine
to the engine's numi.* API
i18n.ts interface strings (en/ru) i18n.ts interface strings (en/ru)
updater.ts checks tauri-plugin-updater, installs + relaunches updater.ts checks tauri-plugin-updater, installs + relaunches
logger.ts thin console wrapper (info/warn/error)
src-tauri/ src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart) (open.er-api.com + CoinGecko) with cache, backups,
with cache, historical rates (frankfurter.dev, permanent data-folder migration, file drops, plugin registration
per-date cache), backups, data-folder migration, (autostart, global-shortcut, single-instance, opener,
file drops, plugin registration (autostart, dialog, updater, process)
global-shortcut, single-instance, opener, dialog,
updater, process)
``` ```
## How a line becomes a result ## How a line becomes a result
@ -97,93 +89,11 @@ snapshot price) plus the mirror list in `src-tauri/src/main.rs`.
because an "attosecond" symbol would shadow the word `as`. because an "attosecond" symbol would shadow the word `as`.
- `and`/`or` are dual-purpose: `and` adds (`5 and 3` = 8), `or` is bitwise. - `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. `of/off/on` only act on a percent left-hand side; otherwise they're prose.
The same `on` token doubles as the historical-rate date separator (`in EUR on
2024-01-01`) — `parseSeq` checks for `pctop(on)` + `datelit` right after a
currency conv target and consumes both when present.
- Currency rates are "units per USD"; a currency Unit's ratio is computed at - 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 tokenization time from the current rate map, so re-setting rates re-prices
everything on the next evaluation. Historical rates live in a separate everything on the next evaluation.
`historicalRates: Map<date, Map<code, number>>` on `SumEngine` and are
passed through `EvalCtx` to `evalConv`.
- Live market data (stocks, commodities via Yahoo Finance) is merged into the
same rate map as exchange rates via `applyAllRates()` in `main.ts` to avoid
one `setRates()` call overwriting the other.
- Goal seek (`? * 1.2 = 1000`) tokenizes `?` as `{ t: "unknown" }`, parsed
into `{ k: "unknown" }` nodes. `parseLine` detects the presence of `unknown`
+ `assign` and 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 by `evalAgg("chart", …)` and
bypasses `formatValue` — the results overlay renders an SVG sparkline
instead of a text string.
- The registry is rebuilt never; extensions mutate it (add phrases/functions) - The registry is rebuilt never; extensions mutate it (add phrases/functions)
at startup. `buildRegistry()` (`registry.ts`) is a thin orchestrator over at startup.
`registerCoreVocab` / `registerUnits` / `registerAreaVolume` /
`registerCurrencies` / `registerExtraUnits` / `registerCrypto` /
`registerCompletions` — **call order matters**, since `Registry.addPhrase`
keeps the *first* registration of an identical phrase and drops later ones
(core vocab must win over unit/currency phrases that happen to collide).
- Multiplying two same-dimension quantities (`2 kg * 500 g`) converts the
right operand into the left operand's unit before multiplying, so the
result keeps a sane unit (`1 kg`, not `1000` in a `kg` label). Cross-dimension
multiplication (`$12 * 3`, `5 hours * 2`) is unaffected — the right side is
treated as a plain scale factor there.
- A `//` only starts a comment at the start of a line or after whitespace
(`findCommentStart` in `index.ts`), so a URL like `see https://example.com`
isn't truncated at the double slash.
- **Date literals and formats.** Besides ISO (`2024-01-01`), the lexer accepts
`D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) with the same separator
on both sides and a 4-digit year. Day/month order is disambiguated per
literal: a component `>12` fixes which side is the day; if both are `≤12`,
the engine's `dateFormat` setting decides. All three literal forms reject
out-of-range components via a round-trip check (`validDateMs` in `lexer.ts`
— `new Date(y, mo-1, d)` silently *rolls over* invalid dates like
`2026-13-45` instead of erroring, so the constructed date's year/month/day
are compared back against the input). `SumEngine` resolves `dateFormat:
"system"` to an actual day/month order once via `detectDateOrder()`
(`datetime.ts`, probes `Intl.DateTimeFormat` on a `2000-12-31` fixture) and
passes it into `tokenize()` → `lexLine()` on every call; `"dmy"`/`"mdy"`
settings skip detection. Display works the same way in reverse: `formatter.ts`
either uses the OS-locale `Intl` formatting (`"system"`) or builds a fixed
`YYYY-MM-DD` / `DD.MM.YYYY` / `MM/DD/YYYY` string via
`Intl.DateTimeFormat(...).formatToParts()` (needed to respect the value's
own IANA timezone, which a hand-rolled `ms`-based formatter can't do).
`Intl.DateTimeFormat` instances are cached by options key in `formatter.ts`
(`dtf()`) since constructing one is comparatively expensive and every dated
line rebuilds its format on every render.
- Timezone name parsing (`time in Europe/Berlin`) has two independent traps.
First, `parseLine`'s noise-filter only keeps word tokens that immediately
follow a `conv` token (`in`/`as`/...); any other token type resets that
state — a bare `/` between two timezone words would normally drop the
second word before the parser ever sees it, so the filter special-cases a
`/` that appears while collecting post-conv words. Second, `parseTarget()`'s
word-collecting loop mirrors that same allowance so it keeps consuming past
the `/` instead of stopping at the first word. Both places need the
exception — fixing only one still drops `Europe/Berlin` down to `Europe`.
## Cross-sheet references
`@Sheet.key` is lexed directly in `lexer.ts` (not through the phrase
registry — sheet titles are arbitrary user text, not vocabulary), producing
an `xref` lexeme carrying `{ sheet, key }`. The engine only knows how to
*ask* for a value: `EvalCtx.resolveXRef?.(sheet, key)` returns
`{ ok: true; value } | { ok: false; reason }`; when no resolver is supplied
(tests, extensions, `evaluateExpression`) an xref is simply unresolved.
Unresolved xrefs throw `XRefError` internally, caught in
`SumEngine.evaluateDocument` and surfaced as `LineResult.error` (rendered as
`#ref?` in the results column) — every other evaluation error still resolves
to a silent blank line, unchanged.
The engine has no concept of "other sheets" — that lives entirely in
`src/workspace.ts`. `Workspace` reads all sheet texts from the app's
in-memory `data.contents`, caches each sheet's exports (its assigned
variables, `total`, `last`) after first evaluating it, and invalidates a
sheet's cache plus every transitive dependent (found via a text scan for
`@Name.`) when it's edited or rates refresh. `Workspace.evaluateSheet(id,
text)` pushes `id` onto a resolving-stack for the duration of the call, so a
sheet that references itself — directly or through another sheet — hits an
already-on-the-stack check and resolves to `circular reference` instead of
recursing forever.
## UI notes ## UI notes
@ -195,19 +105,10 @@ recursing forever.
scroll/geometry changes. Click-to-copy reads `data-value`. scroll/geometry changes. Click-to-copy reads `data-value`.
- Engine evaluation is synchronous on every keystroke — a full sheet parse - Engine evaluation is synchronous on every keystroke — a full sheet parse
is well under a millisecond, so there's no debounce or worker. is well under a millisecond, so there's no debounce or worker.
- Search-all-sheets (`Ctrl+F`) is a plain line scan over every open sheet's - Search-all-sheets (`Ctrl+F`) is a plain line scan over `data.contents` in
text, not a CodeMirror search extension — it has to cross documents, so it `main.ts`, not a CodeMirror search extension — it has to cross documents,
never touches the active editor until a result is clicked so it never touches the active editor until a result is clicked
(`editor.goToLine`). It lives in `src/ui/search.ts` (`initSearch()`), which (`editor.goToLine`).
owns the `#search-overlay` DOM and its own 150 ms input debounce — a
result-value query (`>1000`) re-evaluates every open sheet, so debouncing
matters once there are more than a couple of sheets open.
- The settings popover is split into four tabs (General / Format / Data /
Updates) in `index.html`, each a `.settings-tabpanel` toggled by a
`#settings-tabs .tab` button in `bindSettingsUI()` (`main.ts`) — plain
`classList.toggle`, no router. Tab styling reuses the sidebar's
`doc-item.active` idiom (`background: var(--sel)` for the active pill)
rather than introducing a new visual pattern.
## Storage ## Storage
@ -218,96 +119,21 @@ recursing forever.
deleted sheets become `.numi` files under `backups/deleted/` and are pruned deleted sheets become `.numi` files under `backups/deleted/` and are pruned
by age. Saving is debounced at 400 ms. by age. Saving is debounced at 400 ms.
## Platforms and packaging
- One codebase, three desktop targets. `tauri.conf.json` holds everything
shared; the per-platform bits live in `tauri.windows.conf.json`,
`tauri.linux.conf.json` and `tauri.macos.conf.json`, which Tauri merges over
the base config (JSON Merge Patch) according to the target being built. Each
one only sets `bundle.targets` and that platform's bundler options — NSIS on
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS.
- `release.yml` builds five artifacts on three runner families: Windows x64 and
arm64, Linux x64 (`ubuntu-22.04` — the oldest runner carrying webkit2gtk-4.1,
which keeps the glibc floor low), macOS arm64 and x64. Two separate macOS
targets rather than `universal-apple-darwin`: it matches how Windows already
ships per-architecture, halves each download, and gives the updater the
`darwin-aarch64`/`darwin-x86_64` keys it looks up by default.
- The whole matrix stays `max-parallel: 1`. Every job hands its assets to the
*same* GitHub Release object, and tauri-action's find-or-create check races if
two jobs reach it at once — adding platforms did not change that constraint,
it made it apply to more jobs.
- No OS code signing anywhere. Windows ships unsigned; macOS is *ad-hoc* signed
(`bundle.macOS.signingIdentity: "-"`), which needs no Apple certificate but is
required for Apple Silicon to run a downloaded binary at all — without it the
arm64 build is rejected as damaged rather than merely warned about. Gatekeeper
still asks the user to allow the app on first launch either way.
- `bundle.fileAssociations` covers `.numi`/`.sum` on Windows (NSIS registry
entries) and macOS (`CFBundleDocumentTypes`). On Linux it writes nothing
useful: registering an extension there also needs a shared-mime-info XML that
Tauri does not generate, so `.numi` files are opened by dragging them into the
window rather than from the file manager.
- `icons/icon.icns` is emitted by `scripts/gen-icon.ts` alongside the PNGs and
the `.ico`, hand-rolled the same dependency-free way: `icns` magic + total
length, then one `[OSType][length][PNG]` chunk per size, using the modern
PNG-payload tags `iconutil` produces (`icp4`/`icp5`, `ic07`–`ic14`). Rerun it
with `npm run icons` after touching the artwork.
## Auto-update ## Auto-update
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed - `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
update is available the user is prompted, then `downloadAndInstall()` + update is available the user is prompted, then `downloadAndInstall()` +
`tauri-plugin-process`'s `relaunch()`. `checkForUpdate()` returns `tauri-plugin-process`'s `relaunch()`.
`AvailableUpdate | null | "error"` — `null` means the check succeeded and
found nothing newer, `"error"` means the check itself failed (offline,
signature mismatch, etc.); the distinction only matters for the manual path
below. Every outcome is now visible in devtools: failures via
`console.warn`, and a successful "no update" result logs the running
version alongside "that's the latest published release" via `console.info`
— the most common false alarm is simply that the installed build already
*is* the newest release (e.g. right after installing it), which used to be
indistinguishable from a silently broken check.
- Closing the window hides it to the tray instead of exiting the process (see
`on_window_event` in `main.rs`), so a boot-time-only check can go unnoticed
for a long time on a machine that isn't restarted often. `main.ts` also
reruns `checkUpdate()` on a `setInterval` (every 6 hours) so a long-lived
tray process still notices new releases, and Settings → Updates has a
manual "Check for updates" button (`checkUpdate(true)`) that reports
"up to date" / "check failed" via toast — those toasts are suppressed on
the automatic background checks so the app doesn't nag every few hours.
A `SettingsData.autoUpdateEnabled` toggle turns off both the boot check and
the periodic recheck (re-read from `settings` on every interval tick, so
flipping it while the app is running takes effect immediately); the manual
button ignores the toggle and always works.
- The update endpoint is `latest.json` published alongside each GitHub - The update endpoint is `latest.json` published alongside each GitHub
Release. `release.yml` signs every platform's updater artifact with a Release. `release.yml` signs both architectures with a minisign keypair:
minisign keypair (this is Tauri's own update signing, unrelated to OS code
signing):
the public half is embedded in `tauri.conf.json`; the private half + its the public half is embedded in `tauri.conf.json`; the private half + its
password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets
(write-only — there is no durable copy in the repo). Losing that key means (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. 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 (open.er-api.com + CoinGecko) | 1 hour |
| `market.json` | stock/commodity prices (Yahoo Finance v8 chart, one request per symbol) | 15 minutes |
| `rates-YYYY-MM-DD.json` | historical ECB rates for one date (frankfurter.dev) | permanent |
| `backups/documents-YYYY-MM-DD.json` | daily snapshots | 14 days |
| `backups/deleted/*.numi` | soft-deleted sheets | configurable |
## Tests ## Tests
`npm test` runs 373 vitest cases across 21 files: `src/engine/*.test.ts` covers every `npm test` runs ~96 vitest cases over the engine (`src/engine/*.test.ts`):
expression class, both languages, deterministic injected rates, goal seek, every expression class, both languages, deterministic injected rates,
historical rates (injected), date-format literals/display (dedicated engines plus a regression suite covering all known-fixed bugs.
with an explicit `dateFormat`, since the default "system" format depends on UI is exercised manually; the engine is where the complexity lives.
the CI machine's locale), plus a regression suite covering all known-fixed
bugs; `src/workspace.test.ts` covers cross-sheet references, caching and
cycle detection. UI is exercised manually; the engine is where the complexity
lives.

View file

@ -22,26 +22,18 @@ src/
index.ts фасад SumEngine + API расширений + итоги листа index.ts фасад SumEngine + API расширений + итоги листа
ui/ ui/
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
search.ts оверлей поиска по всем листам (текст + запросы по значению)
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
sparkline.ts рендер SVG-спарклайна для результатов `chart`
app.css темы (светлая/тёмная через CSS-переменные) app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd main.ts запуск приложения: документы, настройки, строка статуса, dnd
workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов
storage.ts команды Tauri / запасной вариант на localStorage storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы в песочнице extensions.ts запускает пользовательские .js-файлы через движок
QuickJS/WASM, мост к API numi.* движка
i18n.ts строки интерфейса (en/ru) i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск updater.ts проверка tauri-plugin-updater, установка + перезапуск
logger.ts тонкая обёртка над console (info/warn/error)
src-tauri/ src-tauri/
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart) (open.er-api.com + CoinGecko) с кэшем, резервные копии,
с кэшем, исторические курсы (frankfurter.dev, постоянный миграция папки данных, открытие файлов, регистрация
кэш на каждую дату), резервные копии, миграция папки плагинов (autostart, global-shortcut, single-instance,
данных, открытие файлов, регистрация плагинов (autostart, opener, dialog, updater, process)
global-shortcut, single-instance, opener, dialog,
updater, process)
``` ```
## Как строка превращается в результат ## Как строка превращается в результат
@ -97,94 +89,11 @@ src-tauri/
только ms/µs/ns/ps, потому что символ «аттосекунды» затенил бы слово `as`. только ms/µs/ns/ps, потому что символ «аттосекунды» затенил бы слово `as`.
- `и`/`или` двойного назначения: `и` складывает (`5 и 3` = 8), `или` — побитовое. - `и`/`или` двойного назначения: `и` складывает (`5 и 3` = 8), `или` — побитовое.
`от/без/на` работают только с процентом в левой части; иначе это просто текст. `от/без/на` работают только с процентом в левой части; иначе это просто текст.
Тот же токен `на` используется как разделитель даты для исторических курсов
(`в EUR на 2024-01-01`) — `parseSeq` проверяет `pctop(on)` + `datelit`
сразу после цели конвертации валюты и поглощает оба при совпадении.
- Курсы валют — «единиц за 1 USD»; соотношение единицы валюты вычисляется при - Курсы валют — «единиц за 1 USD»; соотношение единицы валюты вычисляется при
вычислении из текущей карты курсов, поэтому обновление курсов пересчитывает токенизации из текущей карты курсов, поэтому обновление курсов пересчитывает
всё при следующей оценке. Исторические курсы живут отдельно в всё при следующей оценке.
`historicalRates: Map<date, Map<code, number>>` на `SumEngine` и передаются
через `EvalCtx` в `evalConv`.
- Живые рыночные данные (акции, сырьё через Yahoo Finance) объединяются в ту же
карту курсов через `applyAllRates()` в `main.ts`, чтобы один вызов `setRates()`
не перезаписывал другой.
- Поиск неизвестного (`? * 1.2 = 1000`): `?` токенизируется как `{ t: "unknown" }`,
разбирается в узел `{ k: "unknown" }`. `parseLine` обнаруживает `unknown` + `assign`
и строит `{ k: "goalseek", lhs, rhs }`. Решатель пробует линейный метод (f(0),
f(1) → точный корень для аффинных выражений) и переходит к бисекции для нелинейных.
- `ChartValue` (`kind: "chart"`) возвращается из `evalAgg("chart", …)` и
минует `formatValue` — оверлей результатов рисует SVG-спарклайн вместо текста.
- Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции) - Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции)
при запуске. `buildRegistry()` (`registry.ts`) — тонкий оркестратор над при запуске.
`registerCoreVocab` / `registerUnits` / `registerAreaVolume` /
`registerCurrencies` / `registerExtraUnits` / `registerCrypto` /
`registerCompletions` — **порядок вызовов важен**: `Registry.addPhrase`
оставляет *первую* регистрацию одинаковой фразы и отбрасывает последующие
(базовый словарь обязан побеждать фразы единиц/валют, если они случайно
совпадают).
- Умножение двух величин одного измерения (`2 kg * 500 g`) конвертирует
правый операнд в единицу левого перед умножением, чтобы результат имел
осмысленную единицу (`1 kg`, а не `1000` в подписи `kg`). Умножение разных
измерений (`$12 * 3`, `5 hours * 2`) не затронуто — там правая часть
трактуется как простой числовой множитель.
- `//` начинает комментарий только в начале строки или после пробела
(`findCommentStart` в `index.ts`), поэтому URL вида `see https://example.com`
не обрезается на двойном слэше.
- **Литералы и форматы дат.** Помимо ISO (`2024-01-01`), лексер понимает
`D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) с одинаковым
разделителем с обеих сторон и 4-значным годом. Порядок день/месяц
разрешается для каждого литерала отдельно: компонент `>12` однозначно
определяет, где день; если оба `≤12`, решает настройка движка `dateFormat`.
Все три формы литералов отклоняют компоненты вне диапазона через
round-trip проверку (`validDateMs` в `lexer.ts` — `new Date(y, mo-1, d)`
молча *перекатывает* невалидные даты вроде `2026-13-45` вместо ошибки,
поэтому построенные год/месяц/день сравниваются обратно со входом).
`SumEngine` разрешает `dateFormat: "system"` в реальный порядок день/месяц
один раз через `detectDateOrder()` (`datetime.ts`, пробует
`Intl.DateTimeFormat` на фикстуре `2000-12-31`) и передаёт его в
`tokenize()` → `lexLine()` при каждом вызове; настройки `"dmy"`/`"mdy"`
пропускают детекцию. Отображение работает так же в обратную сторону:
`formatter.ts` либо использует форматирование `Intl` по локали ОС
(`"system"`), либо строит фиксированную строку `YYYY-MM-DD` / `DD.MM.YYYY` /
`MM/DD/YYYY` через `Intl.DateTimeFormat(...).formatToParts()` (это нужно,
чтобы уважать собственный IANA-часовой пояс значения, чего форматтер на
основе голого `ms` сделать не может). Экземпляры `Intl.DateTimeFormat`
кэшируются по ключу опций в `formatter.ts` (`dtf()`), так как их создание
сравнительно дорого, а каждая строка с датой пересобирает формат при
каждой отрисовке.
- У разбора названий часовых поясов (`time in Europe/Berlin`) есть две
независимые ловушки. Во-первых, шумовой фильтр `parseLine` оставляет
word-токены только сразу после `conv`-токена (`in`/`as`/...); любой другой
тип токена сбрасывает это состояние — голый `/` между двумя словами
часового пояса обычно отбросил бы второе слово раньше, чем его увидит
парсер, поэтому фильтр отдельно разрешает `/`, встреченный при сборе
post-conv слов. Во-вторых, цикл сбора слов в `parseTarget()` зеркалит то же
исключение, чтобы продолжать читать после `/`, а не останавливаться на
первом слове. Нужны оба места — исправление только одного всё равно
урезает `Europe/Berlin` до `Europe`.
## Ссылки между листами
`@Sheet.key` лексится напрямую в `lexer.ts` (не через реестр фраз — названия
листов это произвольный текст пользователя, а не словарь), давая лексему
`xref` с `{ sheet, key }`. Движок умеет только *спросить* значение:
`EvalCtx.resolveXRef?.(sheet, key)` возвращает
`{ ok: true; value } | { ok: false; reason }`; без резолвера (тесты,
расширения, `evaluateExpression`) ссылка просто не резолвится. Нерешённые
ссылки бросают `XRefError` внутри, он ловится в `SumEngine.evaluateDocument`
и превращается в `LineResult.error` (рисуется как `#ref?` в колонке
результатов) — все прочие ошибки вычисления по-прежнему тихо дают пустую
строку, это не изменилось.
Движок не знает про "другие листы" — это целиком живёт в `src/workspace.ts`.
`Workspace` читает тексты всех листов из `data.contents` приложения,
кэширует экспорт каждого листа (его присвоенные переменные, `total`, `last`)
после первого вычисления, и сбрасывает кэш листа плюс всех его транзитивных
зависимых (находятся сканированием текста на `@Name.`) при правке листа или
обновлении курсов. `Workspace.evaluateSheet(id, text)` кладёт `id` в стек
резолвинга на всё время вызова, поэтому лист, ссылающийся сам на себя —
напрямую или через другой лист — сразу натыкается на проверку "уже в
стеке" и получает `circular reference` вместо бесконечной рекурсии.
## Особенности UI ## Особенности UI
@ -196,20 +105,10 @@ src-tauri/
прокрутке/изменении геометрии. Клик для копирования читает `data-value`. прокрутке/изменении геометрии. Клик для копирования читает `data-value`.
- Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа - Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа
занимает менее миллисекунды, поэтому нет дебаунса и воркеров. занимает менее миллисекунды, поэтому нет дебаунса и воркеров.
- Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование текста - Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование
каждого открытого листа, а не расширение поиска CodeMirror — ему нужно `data.contents` в `main.ts`, а не расширение поиска CodeMirror — ему нужно
пересекать документы, поэтому он не трогает активный редактор, пока не пересекать документы, поэтому он не трогает активный редактор, пока не
кликнут результат (`editor.goToLine`). Живёт в `src/ui/search.ts` кликнут результат (`editor.goToLine`).
(`initSearch()`), который владеет DOM `#search-overlay` и собственным
дебаунсом ввода на 150 мс — запрос по значению (`>1000`) пересчитывает
каждый открытый лист, так что дебаунс важен, как только листов становится
больше пары.
- Поповер настроек разбит на четыре вкладки (Общие / Формат / Данные /
Обновления) в `index.html`, каждая — `.settings-tabpanel`, переключаемая
кнопкой `#settings-tabs .tab` в `bindSettingsUI()` (`main.ts`) — обычный
`classList.toggle`, без роутера. Стиль вкладок повторяет идиому сайдбара
`doc-item.active` (`background: var(--sel)` для активной таблетки) вместо
нового визуального паттерна.
## Хранилище ## Хранилище
@ -220,98 +119,22 @@ src-tauri/
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту. удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
Сохранение откладывается на 400 мс. Сохранение откладывается на 400 мс.
## Платформы и упаковка
- Одна кодовая база, три десктопных платформы. Всё общее лежит в
`tauri.conf.json`, платформенные куски — в `tauri.windows.conf.json`,
`tauri.linux.conf.json` и `tauri.macos.conf.json`, которые Tauri накладывает
поверх базового конфига (JSON Merge Patch) в зависимости от собираемой цели.
Каждый задаёт только `bundle.targets` и опции своего упаковщика: NSIS на
Windows, deb/rpm/AppImage на Linux, .app/.dmg на macOS.
- `release.yml` собирает пять артефактов на трёх видах раннеров: Windows x64 и
arm64, Linux x64 (`ubuntu-22.04` — самый старый раннер с webkit2gtk-4.1, что
держит планку glibc низко), macOS arm64 и x64. Две отдельные macOS-цели вместо
`universal-apple-darwin`: так же, как Windows уже собирается по архитектурам,
вдвое меньше вес загрузки, и апдейтер получает ключи `darwin-aarch64` /
`darwin-x86_64`, которые ищет по умолчанию.
- Вся матрица остаётся `max-parallel: 1`. Все джобы отдают артефакты в *один и
тот же* объект GitHub Release, а проверка find-or-create в tauri-action гонит,
если две джобы доходят до неё одновременно — добавление платформ это
ограничение не сняло, а распространило на большее число джоб.
- Подписи ОС нет нигде. Windows выходит неподписанным; macOS подписан *ad-hoc*
(`bundle.macOS.signingIdentity: "-"`) — сертификат Apple для этого не нужен, но
без него Apple Silicon вообще откажется запускать скачанный бинарник (сочтёт
повреждённым, а не просто предупредит). Gatekeeper в любом случае попросит
пользователя разрешить приложение при первом запуске.
- `bundle.fileAssociations` покрывает `.numi`/`.sum` на Windows (записи в реестр
из NSIS) и macOS (`CFBundleDocumentTypes`). На Linux он не даёт ничего
полезного: там для регистрации расширения нужен ещё XML для shared-mime-info,
который Tauri не генерирует, так что `.numi` открываются перетаскиванием в
окно, а не из файлового менеджера.
- `icons/icon.icns` создаёт `scripts/gen-icon.ts` рядом с PNG и `.ico`, вручную
и без зависимостей: магия `icns` + общая длина, дальше по чанку
`[OSType][длина][PNG]` на размер, с современными PNG-тегами, которые выдаёт
`iconutil` (`icp4`/`icp5`, `ic07`–`ic14`). После правки картинки перегенерить
через `npm run icons`.
## Автообновление ## Автообновление
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если - `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
подписанное обновление доступно, пользователю предлагают установить, затем подписанное обновление доступно, пользователю предлагают установить, затем
`downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`. `downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`.
`checkForUpdate()` возвращает `AvailableUpdate | null | "error"` — `null`
значит проверка прошла успешно и ничего новее нет, `"error"` значит сама
проверка не удалась (офлайн, рассинхрон подписи и т.п.); различие важно
только для ручного пути ниже. Теперь виден любой исход: сбои через
`console.warn`, а успешный результат «нет обновления» логирует текущую
версию рядом с «это последний опубликованный релиз» через `console.info` —
самый частый ложный сигнал тревоги это когда установленная сборка И ЕСТЬ
последний релиз (например, сразу после его установки), что раньше было
неотличимо от молча сломанной проверки.
- Закрытие окна прячет его в трей вместо завершения процесса (см.
`on_window_event` в `main.rs`), поэтому проверка только при старте может
долго оставаться незамеченной на машине, которую редко перезагружают.
`main.ts` также перезапускает `checkUpdate()` через `setInterval` (каждые
6 часов), чтобы долгоживущий трей-процесс всё равно замечал новые релизы,
а в настройках (вкладка «Обновления») есть кнопка «Проверить обновления»
(`checkUpdate(true)`), которая сообщает «последняя версия» / «не удалось
проверить» через toast — эти уведомления подавлены на автоматических
фоновых проверках, чтобы приложение не напоминало о себе каждые несколько
часов. Тумблер `SettingsData.autoUpdateEnabled` отключает и проверку при
запуске, и периодическую (значение перечитывается из `settings` на каждом
тике интервала, так что переключение прямо во время работы срабатывает
сразу); ручная кнопка тумблер игнорирует и работает всегда.
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub - Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
Release. `release.yml` подписывает артефакт обновления каждой платформы парой Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
ключей minisign (это собственная подпись обновлений Tauri, к подписи кода в ОС
отношения не имеет):
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
(write-only — в репозитории нет постоянной копии). Потеря этого ключа (write-only — в репозитории нет постоянной копии). Потеря этого ключа
означает, что уже установленные копии никогда больше не смогут доверять означает, что уже установленные копии никогда больше не смогут доверять
будущему подписанному релизу. будущему подписанному релизу.
## Хранилище (кэш-файлы)
Все файлы ниже живут в `%APPDATA%/app.summarum.calc` (или в папке данных пользователя):
| Файл | Содержимое | TTL |
|------|-----------|-----|
| `settings.json` | настройки | постоянно |
| `documents.json` | все листы | постоянно (дебаунс 400 мс) |
| `rates.json` | живые курсы валют (open.er-api.com + CoinGecko) | 1 час |
| `market.json` | котировки акций/сырья (Yahoo Finance v8 chart, по запросу на символ) | 15 минут |
| `rates-YYYY-MM-DD.json` | исторические курсы ЕЦБ за одну дату (frankfurter.dev) | навсегда |
| `backups/documents-YYYY-MM-DD.json` | ежедневные снимки | 14 дней |
| `backups/deleted/*.numi` | мягко удалённые листы | настраивается |
## Тесты ## Тесты
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все `npm test` запускает ~96 vitest-кейсов по движку (`src/engine/*.test.ts`):
классы выражений, оба языка, детерминированные инжектированные курсы, поиск все классы выражений, оба языка, детерминированные инжектированные курсы,
неизвестного, исторические курсы (инжектированные), литералы и отображение плюс набор регрессий по всем известным исправленным багам.
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по UI проверяется вручную; движок — там где живёт вся сложность.
умолчанию зависит от локали машины CI), плюс набор регрессий по всем известным
исправленным багам; `src/workspace.test.ts` покрывает межлистовые ссылки,
кэширование и обнаружение циклов. UI проверяется вручную; движок — там где
живёт вся сложность.

102
README.md
View file

@ -17,11 +17,7 @@ total → $1,066.97
$2000 in rubles → 144,060 ₽ $2000 in rubles → 144,060 ₽
20% off 1500 → 1,200 20% off 1500 → 1,200
today + 6 weeks → 22 July 2026 today + 6 weeks → 22 July 2026
1000 USD in EUR on 2024-01-01 → € 917.30 time in Tokyo → 14:25
? * 1.2 = 1000 → 833.33
AAPL in USD → $213.55
days until 2027-01-01 → 183 day
chart → ▂▄▇█▆
``` ```
Works in **English and Russian** at the same time: `5 метров в см`, `20$ в евро`, Works in **English and Russian** at the same time: `5 метров в см`, `20$ в евро`,
@ -29,37 +25,23 @@ Works in **English and Russian** at the same time: `5 метров в см`, `20
## Install ## Install
Grab the build for your system from [Releases](../../releases). The whole app Grab the latest `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows
is about 2 MB and checks for updates on its own after that. on ARM) from [Releases](../../releases), run it, done. The whole app is
about 2 MB. It checks for updates on its own after that.
- **Windows** — `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows on
ARM). Run it, done.
- **Linux** — `.AppImage` (`chmod +x`, then run) or the `.deb`/`.rpm` if you'd
rather install it properly. Self-updating only works on the AppImage; deb and
rpm are updated by downloading the next one.
- **macOS** — `.dmg`, Apple Silicon or Intel. These builds are not signed with
an Apple Developer certificate, so the first launch has to be a right-click →
**Open** rather than a double-click (or run
`xattr -cr /Applications/Summarum.app` once in a terminal). Every launch after
that is normal.
Requirements: Requirements:
- **Windows 10 (1803+) or Windows 11**, 64-bit, plus the - **Windows 10 (1803+) or Windows 11**, 64-bit.
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** — - **[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 already part of Windows 11 and most updated Windows 10 systems; if it's
missing, the installer downloads it automatically. missing, the installer downloads it automatically.
- **Linux** with `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) and - Internet connection is only needed for live currency rates — everything
`libayatana-appindicator3` for the tray icon.
- **macOS 10.13** or newer.
- Internet connection is only needed for live rates and market data — everything
else works offline (rates fall back to a cached/bundled snapshot). else works offline (rates fall back to a cached/bundled snapshot).
## What it understands ## What it understands
**Plain math** — `2 + 2`, `8 / (45 - 20%)`, parentheses, `^`, `sqrt`, `sin`, **Plain math** — `2 + 2`, `8 / (45 - 20%)`, parentheses, `^`, `sqrt`, `sin`,
factorials, `random()`, `random(1; 6)`. Decimal-exact: `0.1 + 0.2` is `0.3`, factorials. Decimal-exact: `0.1 + 0.2` is `0.3`, not `0.30000000000000004`.
not `0.30000000000000004`.
**Words as operators** — `12 plus 4`, `20 divided by 4`, `5 и 3`. Text around **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. the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
@ -76,74 +58,43 @@ the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
(refreshed hourly, cached for offline). `$100 + 20%`, `5 eth in USD`, (refreshed hourly, cached for offline). `$100 + 20%`, `5 eth in USD`,
`9800 рублей в долларах`. `9800 рублей в долларах`.
**Live market data** — stock and commodity prices refreshed every 15 minutes:
`100 AAPL in USD`, `2 GC=F in USD` (gold futures), `MSFT + GOOGL`.
**Historical exchange rates** — `1000 USD in EUR on 2024-01-01` looks up the
ECB rate for that exact date (via frankfurter.dev) and caches it permanently.
**Percents the human way** — `20% of 80`, `20% off 1500`, `5 as a % of 25`, **Percents the human way** — `20% of 80`, `20% off 1500`, `5 as a % of 25`,
`20% of what is 30`. `20% of what is 30`.
**Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York` **Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York`,
(also works with a raw IANA id: `time in Europe/Berlin`), `now in unix`, `now in unix`, `1750000000 as date`.
`1750000000 as date`. Date literals: ISO (`2024-01-01 + 30 days`), or
dot/slash with a 4-digit year — `31.12.2024`, `12/31/2024` — where the
day/month order for ambiguous cases follows the Date format setting.
`days until 2027-01-01`, `days since 2020-03-15`.
**Goal seek** — put `?` where the unknown is and an `=` with the target:
`? * 1.2 = 1000` → `833.33`, `100 - ? = 30` → `70`. Works for any expression
solvable by linear probe or bisection.
**Programmer stuff** — `0xFF & 0x0F`, `1 << 4`, `255 in hex`, `in binary`, **Programmer stuff** — `0xFF & 0x0F`, `1 << 4`, `255 in hex`, `in binary`,
plus `in fraction` (0.75 → 3/4) and `in roman` (2026 → MMXXVI). plus `in fraction` (0.75 → 3/4) and `in roman` (2026 → MMXXVI).
**Sheets that calculate** — variables (`rent = $500`), running totals **Sheets that calculate** — variables (`rent = $500`), running totals
(`sum`, `avg`, `prev`, `count`, `min`, `max`, `product`), `#` headers, (`sum`, `avg`, `prev`), `#` headers, `//` comments. The status bar always shows
`//` comments. The status bar always shows the sheet total; select a few lines the sheet total; select a few lines to see just their sum.
to see just their sum. Add `chart` at the end of a block to draw a sparkline.
**Cross-sheet references** — pull a value from another sheet with `@`:
`@Budget.total`, `@Budget.rent`, `@[Trip to Lisbon].food`. A sheet made of
nothing but references becomes a live dashboard:
`@Assets.total - @Debts.total`. Give the source sheet a fixed name (not an
auto-derived one) so the reference doesn't break when its first line changes.
## Handy things ## Handy things
- **Ctrl+Alt+N** (configurable — the settings field records keys you press) - **Ctrl+Alt+N** (configurable — the settings field records keys you press)
shows/hides the window from anywhere. Closing hides to tray. shows/hides the window from anywhere. Closing hides to tray.
- **Ctrl+W** closes the current sheet (asks first if it has content). - **Ctrl+F** (or the 🔍 button) searches every sheet at once, not just the
- **Ctrl+F** (or the 🔍 button) searches every sheet at once — by text or by one you're in — click a hit to jump straight to that line.
result value: `>1000`, `<50`, `=100`, `~100` (±1%). Click a hit to jump there.
- **Click a result** to copy it. Copying a line copies it *with* the answer - **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. (`rent * 3 = $1,500`). **Ctrl+Shift+C** copies the whole sheet with answers.
- **Export as image** — the ⤓ menu includes "Copy as image": renders the sheet
to a PNG and puts it on the clipboard (falls back to file save if the browser
blocks clipboard write).
- **Drag the divider** to resize the results column. - **Drag the divider** to resize the results column.
- **Drop a `.numi`, `.sum`, `.txt` or `.md` file** into the window to open it - **Drop a `.numi`, `.sum`, `.txt` or `.md` file** into the window to open it
as a sheet. as a sheet.
- **Pin and reorder sheets** — click the pin icon on any sheet in the sidebar
to keep it at the top; use ↑↓ to rearrange.
- **Backups are automatic**: a daily snapshot of all sheets (last 14 kept), and - **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 deleted sheets sit in a bin for 14 days (configurable) — restore one by
dragging the file back in. Settings → Backups opens the folder. dragging the file back in. Settings → Backups opens the folder.
- **Data folder is configurable** — point it at OneDrive/Dropbox and your - **Data folder is configurable** — point it at OneDrive/Dropbox and your
sheets follow you between machines. sheets follow you between machines.
- **Always on top** (Settings) pins the window above everything else. - **Always on top** (Settings) pins the window above everything else.
- The app checks for updates on launch and every few hours after that, and - The app checks for updates on launch and offers to install them — no
offers to install them — no manual download-and-reinstall. Settings → manual download-and-reinstall.
Updates → "Check for updates" runs one on demand, or turn automatic - Light/dark theme, en/ru interface, configurable precision and separators.
checking off entirely with the toggle next to it.
- Light/dark theme, en/ru interface, configurable precision, separators, and
date format (system locale, `31.12.2024`, `12/31/2024`, or `2024-12-31`).
Settings is split into General / Format / Data / Updates tabs.
## Extensions ## Extensions
> **Note:** extensions run inside a WebAssembly sandbox — no file, network or app access, only the `numi` API below. A script that hangs is cut off; a script that fails is skipped. > **Note:** extensions run as trusted local scripts with access to your files. Only load code you wrote or fully trust.
Drop a `.js` file into the extensions folder (Settings → Extensions folder) Drop a `.js` file into the extensions folder (Settings → Extensions folder)
and restart. The API is compatible with Numi extensions: and restart. The API is compatible with Numi extensions:
@ -169,7 +120,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash ```bash
npm install npm install
npm test # engine tests (373 cases) npm test # engine tests
npm run tauri dev # run the app npm run tauri dev # run the app
npm run tauri build # build the installer npm run tauri build # build the installer
``` ```
@ -178,28 +129,19 @@ To build from source you need:
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on - [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
Node's built-in TypeScript support) Node's built-in TypeScript support)
- [Rust](https://rustup.rs/), stable - [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- On Windows: the MSVC toolchain plus - [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
with the "Desktop development with C++" workload (the MSVC linker) with the "Desktop development with C++" workload (the MSVC linker)
- On Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` and a C toolchain
- On macOS: Xcode Command Line Tools
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page - See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
if anything refuses to compile if anything refuses to compile
`npm run tauri build` produces whatever the host platform bundles — NSIS on
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS. Cross-compiling
between the three is not supported; the release workflow builds each on its
own runner.
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6. Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Credits ## Credits
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev — 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 if you're on macOS, buy it. Summarum is an independent project, built from
scratch for Windows, Linux and macOS. scratch for Windows.
## License ## License

View file

@ -16,47 +16,28 @@
$2000 в рублях → 144,060 ₽ $2000 в рублях → 144,060 ₽
скидка 20% от 1500 → 1,200 скидка 20% от 1500 → 1,200
сегодня + 6 недель → 22 Июля 2026 сегодня + 6 недель → 22 Июля 2026
1000 USD в EUR на 2024-01-01 → € 917.30 время в Токио → 14:25
? * 1.2 = 1000 → 833.33
AAPL в USD → $213.55
дней до 2027-01-01 → 183 day
график → ▂▄▇█▆
``` ```
Работает на **английском и русском одновременно**: `5 метров в см`, `20$ в евро`, `сумма` — переключать язык не нужно. Работает на **английском и русском одновременно**: `5 метров в см`, `20$ в евро`, `сумма` — переключать язык не нужно.
## Установка ## Установка
Скачай сборку под свою систему из [Releases](../../releases). Приложение весит Скачай последний `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on ARM) из [Releases](../../releases), запусти — готово. Весь установщик около 2 МБ. Дальше обновления проверяются сами.
около 2 МБ, дальше обновления проверяются сами.
- **Windows** — `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on
ARM). Запусти — готово.
- **Linux** — `.AppImage` (`chmod +x` и запускай) либо `.deb`/`.rpm`, если хочется
поставить нормальным пакетом. Самообновление работает только у AppImage, deb и
rpm обновляются скачиванием следующей версии.
- **macOS** — `.dmg` для Apple Silicon или Intel. Сборки не подписаны сертификатом
Apple Developer, поэтому первый запуск — правый клик → **Открыть**, а не двойной
клик (либо один раз выполнить `xattr -cr /Applications/Summarum.app` в терминале).
Дальше запускается как обычно.
Требования: Требования:
- **Windows 10 (1803+) или Windows 11**, 64-бит, и - **Windows 10 (1803+) или Windows 11**, 64-бит.
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** — - **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует, уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует,
установщик скачает его автоматически. установщик скачает его автоматически.
- **Linux** с `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) и - Интернет нужен только для живых курсов валют — всё остальное работает офлайн
`libayatana-appindicator3` для иконки в трее.
- **macOS 10.13** или новее.
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
(курсы берутся из кэша или встроенного снимка). (курсы берутся из кэша или встроенного снимка).
## Что понимает ## Что понимает
**Обычная математика** — `2 + 2`, `8 / (45 - 20%)`, скобки, `^`, `sqrt`, `sin`, **Обычная математика** — `2 + 2`, `8 / (45 - 20%)`, скобки, `^`, `sqrt`, `sin`,
факториалы, `случайное()`, `случайное(1; 6)`. Точная десятичная арифметика: факториалы. Точная десятичная арифметика: `0.1 + 0.2` это `0.3`, а не `0.30000000000000004`.
`0.1 + 0.2` это `0.3`, а не `0.30000000000000004`.
**Слова как операторы** — `12 плюс 4`, `20 делить на 4`, `5 и 3`. Текст вокруг **Слова как операторы** — `12 плюс 4`, `20 делить на 4`, `5 и 3`. Текст вокруг
математики игнорируется: `потратил 20 на пиццу и 5 на кофе` даст 25. математики игнорируется: `потратил 20 на пиццу и 5 на кофе` даст 25.
@ -73,73 +54,42 @@ AAPL в USD → $213.55
(обновляются раз в час, кэшируются для офлайна). `$100 + 20%`, `5 eth в USD`, (обновляются раз в час, кэшируются для офлайна). `$100 + 20%`, `5 eth в USD`,
`9800 рублей в долларах`. `9800 рублей в долларах`.
**Живые рыночные данные** — котировки акций и сырья обновляются каждые 15 минут:
`100 AAPL в USD`, `2 GC=F в USD` (фьючерс на золото), `MSFT + GOOGL`.
**Исторические курсы** — `1000 USD в EUR на 2024-01-01` запрашивает курс ЕЦБ
за этот день (через frankfurter.dev) и кэширует его навсегда.
**Проценты по-человечески** — `20% от 80`, `скидка 20% от 1500`, `5 это сколько % от 25`, **Проценты по-человечески** — `20% от 80`, `скидка 20% от 1500`, `5 это сколько % от 25`,
`20% от какого числа равно 30`. `20% от какого числа равно 30`.
**Даты и время** — `сегодня + 2 недели`, `завтра - сегодня`, `время в Нью-Йорке` **Даты и время** — `сегодня + 2 недели`, `завтра - сегодня`, `время в Нью-Йорке`,
(работает и с прямым IANA-id: `время в Europe/Berlin`), `сейчас в unix`, `сейчас в unix`, `1750000000 как дата`.
`1750000000 как дата`. Форматы дат: ISO (`2024-01-01 + 30 дней`), или через
точку/слэш с 4-значным годом — `31.12.2024`, `12/31/2024` — порядок день/месяц
для неоднозначных случаев берётся из настройки «Формат даты».
`дней до 2027-01-01`, `дней с 2020-03-15`.
**Поиск неизвестного** — поставь `?` на место неизвестного и `=` с нужным результатом:
`? * 1.2 = 1000` → `833.33`, `100 - ? = 30` → `70`. Работает для любых выражений,
решаемых линейным методом или бисекцией.
**Для программистов** — `0xFF & 0x0F`, `1 << 4`, `255 в hex`, `в binary`, **Для программистов** — `0xFF & 0x0F`, `1 << 4`, `255 в hex`, `в binary`,
плюс `в дроби` (0.75 → 3/4) и `в roman` (2026 → MMXXVI). плюс `в дроби` (0.75 → 3/4) и `в roman` (2026 → MMXXVI).
**Листы с переменными** — переменные (`аренда = $500`), нарастающие итоги **Листы с переменными** — переменные (`аренда = $500`), нарастающие итоги
(`сумма`, `avg`, `prev`, `количество`, `минимум`, `максимум`, `произведение`), (`сумма`, `avg`, `prev`), заголовки `#`, комментарии `//`. Строка статуса всегда
заголовки `#`, комментарии `//`. Строка статуса всегда показывает итог листа; показывает итог листа; выдели несколько строк — увидишь их сумму.
выдели несколько строк — увидишь их сумму. Добавь `график` в конце блока чтобы
нарисовать спарклайн.
**Ссылки между листами** — тяни значение из другого листа через `@`:
`@Бюджет.total`, `@Бюджет.аренда`, `@[Поездка в Лиссабон].еда`. Лист из одних
ссылок становится живым дашбордом: `@Активы.total - @Долги.total`. Дай листу-
источнику постоянное имя (не автоматическое), иначе ссылка сломается при
правке первой строки.
## Удобные мелочи ## Удобные мелочи
- **Ctrl+Alt+N** (настраивается — поле в настройках записывает нажатые клавиши) - **Ctrl+Alt+N** (настраивается — поле в настройках записывает нажатые клавиши)
показывает/скрывает окно из любого места. Закрытие прячет в трей. показывает/скрывает окно из любого места. Закрытие прячет в трей.
- **Ctrl+W** закрывает текущий лист (спрашивает подтверждение если есть содержимое). - **Ctrl+F** (или кнопка 🔍) ищет сразу по всем листам, а не только по текущему —
- **Ctrl+F** (или кнопка 🔍) ищет сразу по всем листам — по тексту или по значению клик по результату переносит прямо на нужную строку.
результата: `>1000`, `<50`, `=100`, `~100` (±1%). Клик по результату — переходишь туда.
- **Клик по результату** копирует его. Копирование строки копирует её *вместе с ответом* - **Клик по результату** копирует его. Копирование строки копирует её *вместе с ответом*
(`аренда * 3 = $1,500`). **Ctrl+Shift+C** копирует весь лист с ответами. (`аренда * 3 = $1,500`). **Ctrl+Shift+C** копирует весь лист с ответами.
- **Экспорт картинкой** — меню ⤓ содержит «Скопировать картинкой»: рендерит лист
в PNG и кладёт в буфер обмена (при ошибке предлагает сохранить файл).
- **Потяни разделитель** чтобы изменить ширину колонки результатов. - **Потяни разделитель** чтобы изменить ширину колонки результатов.
- **Брось файл `.numi`, `.sum`, `.txt` или `.md`** в окно — откроется как лист. - **Брось файл `.numi`, `.sum`, `.txt` или `.md`** в окно — откроется как лист.
- **Закрепление и порядок листов** — нажми значок булавки на любом листе в сайдбаре
чтобы закрепить его вверху; кнопки ↑↓ меняют порядок.
- **Резервные копии автоматические**: ежедневный снимок всех листов (хранятся 14 дней), - **Резервные копии автоматические**: ежедневный снимок всех листов (хранятся 14 дней),
удалённые листы лежат в корзине 14 дней (настраивается) — верни перетащив обратно. удалённые листы лежат в корзине 14 дней (настраивается) — верни перетащив обратно.
Настройки → Backups откроет папку. Настройки → Backups откроет папку.
- **Папка данных настраивается** — укажи OneDrive/Dropbox и листы синхронизируются - **Папка данных настраивается** — укажи OneDrive/Dropbox и листы синхронизируются
между машинами. между машинами.
- **Поверх всех окон** (в настройках) закрепляет окно над остальными. - **Поверх всех окон** (в настройках) закрепляет окно над остальными.
- Приложение проверяет обновления при запуске и потом каждые несколько - Приложение само проверяет обновления при запуске и предлагает установить —
часов, предлагая установить — вручную скачивать и переустанавливать не вручную скачивать и переустанавливать не нужно.
нужно. Настройки → Обновления → «Проверить обновления» запускает проверку - Светлая/тёмная тема, интерфейс en/ru, настраиваемая точность и разделители.
вручную, либо отключи автопроверку целиком тумблером рядом.
- Светлая/тёмная тема, интерфейс en/ru, настраиваемая точность, разделители
и формат даты (системный, `31.12.2024`, `12/31/2024` или `2024-12-31`).
Настройки разбиты на вкладки: Общие / Формат / Данные / Обновления.
## Расширения ## Расширения
> **Внимание:** расширения выполняются в WebAssembly-песочнице — без доступа к файлам, сети и приложению, только API `numi` ниже. Зависший скрипт прерывается, упавший — пропускается. > **Внимание:** расширения запускаются как доверенные локальные скрипты с доступом к вашим файлам. Загружайте только код, который вы написали сами или полностью доверяете.
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти. Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
API совместим с расширениями Numi: API совместим с расширениями Numi:
@ -165,7 +115,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
```bash ```bash
npm install npm install
npm test # тесты движка (373 кейса) npm test # тесты движка
npm run tauri dev # запустить приложение npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик npm run tauri build # собрать установщик
``` ```
@ -173,25 +123,17 @@ npm run tauri build # собрать установщик
Для сборки нужны: Для сборки нужны:
- [Node.js](https://nodejs.org/) 22.6 или новее - [Node.js](https://nodejs.org/) 22.6 или новее
- [Rust](https://rustup.rs/), stable - [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- На Windows: toolchain MSVC и - [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
с компонентом «Desktop development with C++» (нужен линковщик MSVC) с компонентом «Desktop development with C++» (нужен линковщик MSVC)
- На Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` и компилятор C
- На macOS: Xcode Command Line Tools
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/) - Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
`npm run tauri build` собирает то, что умеет хост-платформа: NSIS на Windows,
deb/rpm/AppImage на Linux, .app/.dmg на macOS. Кросс-компиляция между ними не
поддерживается — релизный workflow собирает каждую платформу на своём раннере.
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6. Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Благодарность ## Благодарность
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева — Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows, Linux и macOS. если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows.
## Лицензия ## Лицензия

View file

@ -9,33 +9,31 @@
<div id="app"> <div id="app">
<aside id="sidebar" class="hidden"> <aside id="sidebar" class="hidden">
<div id="doc-list"></div> <div id="doc-list"></div>
<button id="new-doc" title="New document" aria-label="New document">+</button> <button id="new-doc" title="New document">+</button>
</aside> </aside>
<main id="content"> <main id="content">
<div id="titlebar" data-tauri-drag-region> <div id="titlebar" data-tauri-drag-region>
<button id="toggle-sidebar" title="Documents (Ctrl+B)" aria-label="Documents">☰</button> <button id="toggle-sidebar" title="Documents (Ctrl+B)">☰</button>
<input id="doc-title" spellcheck="false" autocomplete="off" aria-label="Document title" /> <input id="doc-title" spellcheck="false" autocomplete="off" />
<span class="spacer" data-tauri-drag-region></span> <span class="spacer" data-tauri-drag-region></span>
<button id="open-search" title="Search all sheets (Ctrl+F)" aria-label="Search all sheets">🔍︎</button> <button id="open-search" title="Search all sheets (Ctrl+F)">🔍︎</button>
<button id="open-settings" title="Settings" aria-label="Settings">⚙</button> <button id="open-settings" title="Settings">⚙</button>
<div id="win-controls"> <div id="win-controls">
<button id="win-min" title="Minimize" aria-label="Minimize">─</button> <button id="win-min" title="Minimize">─</button>
<button id="win-close" title="Hide to tray" aria-label="Hide to tray">✕</button> <button id="win-close" title="Hide to tray">✕</button>
</div> </div>
</div> </div>
<div id="editor-wrap"> <div id="editor-wrap">
<div id="editor"></div> <div id="editor"></div>
<div id="results"></div> <div id="results"></div>
<div id="col-divider" title="Drag to resize" aria-label="Drag to resize results column"></div> <div id="col-divider" title="Drag to resize"></div>
</div> </div>
<div id="statusbar"> <div id="statusbar">
<span id="rates-info" title=""></span> <span id="rates-info" title=""></span>
<span id="market-info" title="" class="d-none"></span>
<span class="spacer"></span> <span class="spacer"></span>
<div id="export-wrap"> <div id="export-wrap">
<button id="export-btn" title="Export" aria-label="Export">⤓</button> <button id="export-btn" title="Export">⤓</button>
<div id="export-menu" class="hidden"> <div id="export-menu" class="hidden">
<button data-action="image" data-i18n="exportImage">Copy as image</button>
<button data-action="copy" data-i18n="exportCopy">Copy sheet</button> <button data-action="copy" data-i18n="exportCopy">Copy sheet</button>
<button data-action="save-sum" data-i18n="exportSum">Save as .sum</button> <button data-action="save-sum" data-i18n="exportSum">Save as .sum</button>
<button data-action="export-txt" data-i18n="exportTxt">Export as text</button> <button data-action="export-txt" data-i18n="exportTxt">Export as text</button>
@ -47,95 +45,59 @@
</main> </main>
<div id="settings-panel" class="hidden"> <div id="settings-panel" class="hidden">
<h2 data-i18n="settings">Settings</h2> <h2 data-i18n="settings">Settings</h2>
<div id="settings-tabs" role="tablist"> <label><span data-i18n="theme">Theme</span>
<button class="tab active" data-tab="general" data-i18n="tabGeneral">General</button> <select id="set-theme">
<button class="tab" data-tab="format" data-i18n="tabFormat">Format</button> <option value="system" data-i18n="system">System</option>
<button class="tab" data-tab="data" data-i18n="tabData">Data</button> <option value="light" data-i18n="light">Light</option>
<button class="tab" data-tab="updates" data-i18n="tabUpdates">Updates</button> <option value="dark" data-i18n="dark">Dark</option>
</div> </select>
</label>
<div class="settings-tabpanel" data-tabpanel="general"> <label><span data-i18n="precision">Decimal places</span>
<label><span data-i18n="theme">Theme</span> <input id="set-precision" type="number" min="0" max="15" />
<select id="set-theme"> </label>
<option value="system" data-i18n="system">System</option> <label><span data-i18n="groupsep">Thousands separator</span>
<option value="light" data-i18n="light">Light</option> <select id="set-groupsep">
<option value="dark" data-i18n="dark">Dark</option> <option value=",">1,000</option>
</select> <option value=" ">1 000</option>
</label> <option value="">1000</option>
<label><span data-i18n="language">Language</span> </select>
<select id="set-lang"> </label>
<option value="en">English</option> <label><span data-i18n="decimalsep">Decimal separator</span>
<option value="ru">Русский</option> <select id="set-decimalsep">
</select> <option value=".">0.5</option>
</label> <option value=",">0,5</option>
<label><span data-i18n="fontsize">Font size</span> </select>
<input id="set-fontsize" type="number" min="10" max="32" /> </label>
</label> <label><span data-i18n="language">Language</span>
<label><span data-i18n="resultswidth">Results column</span> <select id="set-lang">
<input id="set-resultswidth" type="range" min="20" max="60" step="1" /> <option value="en">English</option>
</label> <option value="ru">Русский</option>
<label class="row"><input id="set-alwaysontop" type="checkbox" /> </select>
<span data-i18n="alwaysontop">Always on top</span> </label>
</label> <label><span data-i18n="hotkey">Global hotkey</span>
<label><span data-i18n="hotkey">Global hotkey</span> <input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" />
<input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" /> </label>
</label> <label class="row"><input id="set-autostart" type="checkbox" />
<label class="row"><input id="set-autostart" type="checkbox" /> <span data-i18n="autostart">Launch at startup</span>
<span data-i18n="autostart">Launch at startup</span> </label>
</label> <label class="row"><input id="set-alwaysontop" type="checkbox" />
</div> <span data-i18n="alwaysontop">Always on top</span>
</label>
<div class="settings-tabpanel hidden" data-tabpanel="format"> <label><span data-i18n="fontsize">Font size</span>
<label><span data-i18n="precision">Decimal places</span> <input id="set-fontsize" type="number" min="10" max="32" />
<input id="set-precision" type="number" min="0" max="15" /> </label>
</label> <label><span data-i18n="resultswidth">Results column</span>
<label><span data-i18n="groupsep">Thousands separator</span> <input id="set-resultswidth" type="range" min="20" max="60" step="1" />
<select id="set-groupsep"> </label>
<option value=",">1,000</option> <label><span data-i18n="bindays">Bin retention (days)</span>
<option value=" ">1 000</option> <input id="set-bindays" type="number" min="1" max="365" step="1" />
<option value="">1000</option> </label>
</select> <label><span data-i18n="datadir">Data folder</span>
</label> <button id="set-datadir" class="path-btn" title=""></button>
<label><span data-i18n="decimalsep">Decimal separator</span> </label>
<select id="set-decimalsep">
<option value=".">0.5</option>
<option value=",">0,5</option>
</select>
</label>
<label><span data-i18n="dateformat">Date format</span>
<select id="set-dateformat">
<option value="system" data-i18n="sysDefault">System</option>
<option value="dmy">31.12.2024</option>
<option value="mdy">12/31/2024</option>
<option value="iso">2024-12-31</option>
</select>
</label>
</div>
<div class="settings-tabpanel hidden" data-tabpanel="data">
<label><span data-i18n="datadir">Data folder</span>
<button id="set-datadir" class="path-btn" title=""></button>
</label>
<label><span data-i18n="bindays">Bin retention (days)</span>
<input id="set-bindays" type="number" min="1" max="365" step="1" />
</label>
<div class="settings-tabpanel-buttons">
<button id="open-extensions" data-i18n="extensions">Extensions folder</button>
<button id="open-backups" data-i18n="backups">Backups</button>
</div>
</div>
<div class="settings-tabpanel hidden" data-tabpanel="updates">
<label class="row"><input id="set-autoupdate" type="checkbox" />
<span data-i18n="autoUpdate">Automatically check for updates</span>
</label>
<div class="settings-tabpanel-buttons">
<button id="check-update" data-i18n="checkUpdate">Check for updates</button>
</div>
<div id="app-version" class="settings-version"></div>
</div>
<div class="settings-footer"> <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> <button id="close-settings" data-i18n="done">Done</button>
</div> </div>
</div> </div>
@ -151,7 +113,7 @@
</div> </div>
<div id="search-overlay" class="hidden"> <div id="search-overlay" class="hidden">
<div class="search-box"> <div class="search-box">
<input id="search-input" type="text" autocomplete="off" spellcheck="false" aria-label="Search sheets" /> <input id="search-input" type="text" autocomplete="off" spellcheck="false" />
<div id="search-results"></div> <div id="search-results"></div>
</div> </div>
</div> </div>

46
package-lock.json generated
View file

@ -1,19 +1,18 @@
{ {
"name": "summarum", "name": "summarum",
"version": "1.2.9", "version": "1.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "summarum", "name": "summarum",
"version": "1.2.9", "version": "1.0.1",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0", "@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.10.0", "@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0", "@codemirror/view": "^6.36.0",
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0", "@tauri-apps/plugin-autostart": "^2.3.0",
@ -22,8 +21,7 @@
"@tauri-apps/plugin-opener": "^2.2.0", "@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"decimal.js": "^10.4.3", "decimal.js": "^10.4.3"
"quickjs-emscripten-core": "^0.32.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.5.0", "@tauri-apps/cli": "^2.5.0",
@ -534,21 +532,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-singlefile-browser-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-browser-release-sync/-/quickjs-singlefile-browser-release-sync-0.32.0.tgz",
"integrity": "sha512-Hfdl7rh8dzxNWFRiYAYNbhn0RMF1/tO6SMH2mUW0aTibqwaAtqPRbi4WkwaIDlhNz8Z4dksJi1Zjl1R54Jsc/Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@ -1642,9 +1625,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1698,9 +1681,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.24", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -1718,7 +1701,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -1726,15 +1709,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.61.1", "version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",

View file

@ -1,8 +1,8 @@
{ {
"name": "summarum", "name": "summarum",
"private": true, "private": true,
"version": "1.2.9", "version": "1.0.1",
"description": "Summarum — natural language calculator notebook", "description": "Summarum — natural language calculator notebook for Windows",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -19,7 +19,6 @@
"@codemirror/language": "^6.10.0", "@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0", "@codemirror/view": "^6.36.0",
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-autostart": "^2.3.0", "@tauri-apps/plugin-autostart": "^2.3.0",
@ -28,8 +27,7 @@
"@tauri-apps/plugin-opener": "^2.2.0", "@tauri-apps/plugin-opener": "^2.2.0",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"decimal.js": "^10.4.3", "decimal.js": "^10.4.3"
"quickjs-emscripten-core": "^0.32.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.5.0", "@tauri-apps/cli": "^2.5.0",

View file

@ -113,7 +113,7 @@ function draw(size: number): Buffer {
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const pngs = new Map<number, Buffer>(); const pngs = new Map<number, Buffer>();
for (const size of [16, 32, 48, 64, 128, 256, 512, 1024]) { for (const size of [16, 32, 48, 64, 128, 256, 512]) {
pngs.set(size, encodePng(size, draw(size))); pngs.set(size, encodePng(size, draw(size)));
} }
@ -154,38 +154,3 @@ function buildIco(sizes: number[]): Buffer {
fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256])); fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
console.log("icon.ico (6 sizes)"); console.log("icon.ico (6 sizes)");
// .icns: "icns" magic + total length, then [OSType][length incl. header][PNG].
// Both lengths are big-endian and count their own 8-byte header. The tag set
// mirrors what `iconutil` emits for a modern .iconset — every ic*/icp* entry
// below carries a plain PNG payload (10.7+), so no JPEG 2000 encoder is needed.
const ICNS_ENTRIES: Array<[string, number]> = [
["icp4", 16],
["icp5", 32],
["ic11", 32], // 16@2x
["ic12", 64], // 32@2x
["ic07", 128],
["ic13", 256], // 128@2x
["ic08", 256],
["ic14", 512], // 256@2x
["ic09", 512],
["ic10", 1024], // 512@2x
];
function buildIcns(): Buffer {
const parts = ICNS_ENTRIES.map(([tag, size]) => {
const data = pngs.get(size)!;
const head = Buffer.alloc(8);
head.write(tag, 0, 4, "ascii");
head.writeUInt32BE(8 + data.length, 4);
return Buffer.concat([head, data]);
});
const body = Buffer.concat(parts);
const header = Buffer.alloc(8);
header.write("icns", 0, 4, "ascii");
header.writeUInt32BE(8 + body.length, 4);
return Buffer.concat([header, body]);
}
fs.writeFileSync(path.join(outDir, "icon.icns"), buildIcns());
console.log(`icon.icns (${ICNS_ENTRIES.length} entries)`);

38
src-tauri/Cargo.lock generated
View file

@ -1094,21 +1094,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@ -1116,7 +1101,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@ -1184,7 +1168,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@ -3038,7 +3021,6 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@ -3058,14 +3040,12 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@ -3105,7 +3085,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.5.0", "wasm-streams",
"web-sys", "web-sys",
] ]
@ -3727,10 +3707,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "summarum" name = "summarum"
version = "1.2.9" version = "1.0.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"futures",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@ -4927,19 +4906,6 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "summarum" name = "summarum"
version = "1.2.9" version = "1.0.1"
description = "Summarum — natural language calculator notebook" description = "Summarum — natural language calculator notebook for Windows"
edition = "2021" edition = "2021"
[build-dependencies] [build-dependencies]
@ -18,9 +18,8 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
chrono = "0.4" chrono = "0.4"
futures = "0.3.32"
[profile.release] [profile.release]
strip = true strip = true

View file

@ -1,9 +1,9 @@
// WARNING: extensions run as trusted local code with full access to your files.
// Only load scripts you wrote yourself or fully trust.
// Summarum extension example. // Summarum extension example.
// Drop .js files into this folder and restart the app. // Drop .js files into this folder and restart the app.
// API: numi.setVariable, numi.addUnit, numi.addFunction. // API: numi.setVariable, numi.addUnit, numi.addFunction.
//
// Scripts run in a WebAssembly sandbox: no files, no network, no app internals,
// and no console/setTimeout/fetch — only plain JS and the numi API above.
// A global variable usable on any sheet, e.g. `salary * vat` // A global variable usable on any sheet, e.g. `salary * vat`
// numi.setVariable("vat", { "double": 20 }); // numi.setVariable("vat", { "double": 20 });

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Summarum", "productName": "Summarum",
"version": "1.2.9", "version": "1.0.1",
"identifier": "app.summarum.calc", "identifier": "app.summarum.calc",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -23,19 +23,19 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev" "csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com"
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.png", "icons/icon.png",
"icons/icon.ico", "icons/icon.ico"
"icons/icon.icns"
], ],
"fileAssociations": [ "fileAssociations": [
{ {
@ -44,7 +44,13 @@
"description": "Summarum calculation sheet", "description": "Summarum calculation sheet",
"role": "Editor" "role": "Editor"
} }
] ],
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["English", "Russian"]
}
}
}, },
"plugins": { "plugins": {
"updater": { "updater": {

View file

@ -1,5 +0,0 @@
{
"bundle": {
"targets": ["deb", "rpm", "appimage"]
}
}

View file

@ -1,8 +0,0 @@
{
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
"signingIdentity": "-"
}
}
}

View file

@ -1,11 +0,0 @@
{
"bundle": {
"targets": ["nsis"],
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["English", "Russian"]
}
}
}
}

View file

@ -1,209 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import {
detectDateOrder,
resolveZone,
startOfToday,
addToDate,
isCalendarUnit,
} from "./datetime";
describe("datetime", () => {
describe("detectDateOrder", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should detect dmy (e.g. en-GB)", () => {
expect(detectDateOrder("en-GB")).toBe("dmy");
});
it("should detect mdy (e.g. en-US)", () => {
expect(detectDateOrder("en-US")).toBe("mdy");
});
it("should default to dmy if neither is found", () => {
const mockFormatToParts = vi.fn().mockReturnValue([
{ type: "year", value: "2000" },
]);
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
formatToParts: mockFormatToParts,
}) as any);
expect(detectDateOrder()).toBe("dmy");
mockDateTimeFormat.mockRestore();
});
});
describe("resolveZone", () => {
it("should resolve 'local' to system timezone", () => {
const mockResolvedOptions = vi.fn().mockReturnValue({ timeZone: "America/Los_Angeles" });
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
resolvedOptions: mockResolvedOptions,
}) as any);
expect(resolveZone(["local"])).toBe("America/Los_Angeles");
mockDateTimeFormat.mockRestore();
});
it("should resolve known abbreviations", () => {
expect(resolveZone(["utc"])).toBe("UTC");
expect(resolveZone(["est"])).toBe("America/New_York");
expect(resolveZone(["pst"])).toBe("America/Los_Angeles");
expect(resolveZone(["jst"])).toBe("Asia/Tokyo");
});
it("should resolve known cities", () => {
expect(resolveZone(["new", "york"])).toBe("America/New_York");
expect(resolveZone(["london"])).toBe("Europe/London");
expect(resolveZone(["tokyo"])).toBe("Asia/Tokyo");
expect(resolveZone(["buenos", "aires"])).toBe("America/Argentina/Buenos_Aires");
});
it("should resolve russian cities", () => {
expect(resolveZone(["нью", "йорк"])).toBe("America/New_York");
expect(resolveZone(["москва"])).toBe("Europe/Moscow");
});
it("should ignore punctuation and case", () => {
expect(resolveZone(["New-York"])).toBe("America/New_York");
expect(resolveZone(["LONdoN."])).toBe("Europe/London");
});
it("should resolve raw IANA timezone", () => {
expect(resolveZone(["Europe", "Berlin"])).toBe("Europe/Berlin");
expect(resolveZone(["America", "Argentina", "Mendoza"])).toBe("America/Argentina/Mendoza");
});
it("should return null for unknown timezones", () => {
expect(resolveZone(["Unknown", "City"])).toBeNull();
expect(resolveZone(["Invalid/Timezone"])).toBeNull();
});
});
describe("startOfToday", () => {
afterEach(() => {
vi.useRealTimers();
});
it("should return midnight of the current day", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-05-15T14:30:00Z"));
const expected = new Date("2024-05-15T14:30:00Z");
expected.setHours(0, 0, 0, 0);
expect(startOfToday()).toBe(expected.getTime());
});
});
describe("addToDate", () => {
it("should add days", () => {
const ms = new Date("2024-01-01T12:00:00Z").getTime();
const res = addToDate(ms, 5, "day");
expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z");
});
it("should add weeks", () => {
const ms = new Date("2024-01-01T12:00:00Z").getTime();
const res = addToDate(ms, 2, "week");
expect(new Date(res).toISOString()).toBe("2024-01-15T12:00:00.000Z");
});
it("should add months", () => {
const ms = new Date("2024-01-15T12:00:00Z").getTime();
const res = addToDate(ms, 2, "month");
expect(new Date(res).toISOString()).toBe("2024-03-15T12:00:00.000Z");
});
it("should clamp end of month correctly", () => {
const ms = new Date("2024-01-31T12:00:00Z").getTime();
const res = addToDate(ms, 1, "month");
// 2024 is a leap year, so Feb 29
expect(new Date(res).toISOString()).toBe("2024-02-29T12:00:00.000Z");
const ms2 = new Date("2023-01-31T12:00:00Z").getTime();
const res2 = addToDate(ms2, 1, "month");
// 2023 is not a leap year, so Feb 28
expect(new Date(res2).toISOString()).toBe("2023-02-28T12:00:00.000Z");
});
it("should add years", () => {
const ms = new Date("2024-01-15T12:00:00Z").getTime();
const res = addToDate(ms, 3, "year");
expect(new Date(res).toISOString()).toBe("2027-01-15T12:00:00.000Z");
});
it("should clamp end of month when adding years to leap day", () => {
const ms = new Date("2024-02-29T12:00:00Z").getTime();
const res = addToDate(ms, 1, "year");
expect(new Date(res).toISOString()).toBe("2025-02-28T12:00:00.000Z");
});
it("should handle unit ids with prefixes", () => {
const ms = new Date("2024-01-01T12:00:00Z").getTime();
const res = addToDate(ms, 5, "unit:day");
expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z");
});
it("should return original ms if amount is not an integer", () => {
const ms = new Date("2024-01-01T12:00:00Z").getTime();
const res = addToDate(ms, 1.5, "day");
expect(res).toBe(ms);
});
it("should return original ms for unhandled units", () => {
const ms = new Date("2024-01-01T12:00:00Z").getTime();
const res = addToDate(ms, 1, "hour");
expect(res).toBe(ms);
});
});
describe("isCalendarUnit", () => {
it("should return true for valid calendar units with integer amounts", () => {
expect(isCalendarUnit("day", 1)).toBe(true);
expect(isCalendarUnit("week", 5)).toBe(true);
expect(isCalendarUnit("month", -2)).toBe(true);
expect(isCalendarUnit("year", 10)).toBe(true);
expect(isCalendarUnit("unit:day", 1)).toBe(true);
});
it("should return false for floating point amounts", () => {
expect(isCalendarUnit("day", 1.5)).toBe(false);
expect(isCalendarUnit("month", 0.5)).toBe(false);
});
it("should return false for non-calendar units", () => {
expect(isCalendarUnit("hour", 1)).toBe(false);
expect(isCalendarUnit("minute", 1)).toBe(false);
expect(isCalendarUnit("unit:meter", 1)).toBe(false);
});
it("should handle edge cases for amount", () => {
expect(isCalendarUnit("day", 0)).toBe(true);
expect(isCalendarUnit("day", Number.MAX_SAFE_INTEGER)).toBe(true);
expect(isCalendarUnit("day", Number.MIN_SAFE_INTEGER)).toBe(true);
expect(isCalendarUnit("day", NaN)).toBe(false);
expect(isCalendarUnit("day", Infinity)).toBe(false);
expect(isCalendarUnit("day", -Infinity)).toBe(false);
});
it("should handle edge cases for unitId string format", () => {
expect(isCalendarUnit("", 1)).toBe(false);
expect(isCalendarUnit(":", 1)).toBe(false);
// string.split(":") behavior:
expect(isCalendarUnit("time:month", 1)).toBe(true);
expect(isCalendarUnit(":day", 1)).toBe(true);
expect(isCalendarUnit("day:", 1)).toBe(false);
expect(isCalendarUnit("a:day:c", 1)).toBe(true);
});
it("should return false for case-sensitive mismatch", () => {
expect(isCalendarUnit("Day", 1)).toBe(false);
expect(isCalendarUnit("YEAR", 1)).toBe(false);
expect(isCalendarUnit("MONTH", 1)).toBe(false);
});
});
});

View file

@ -1,16 +1,4 @@
// Date helpers and a city → IANA timezone map for "time in Tokyo". // Date helpers and a city → IANA timezone map for "time in Tokyo".
import { DateOrder } from "./types";
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
export function detectDateOrder(locale?: string): DateOrder {
const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" })
.formatToParts(new Date(2000, 11, 31));
for (const p of parts) {
if (p.type === "day") return "dmy";
if (p.type === "month") return "mdy";
}
return "dmy";
}
const Z: Record<string, string> = { const Z: Record<string, string> = {
// ---- common abbreviations // ---- common abbreviations
@ -105,13 +93,8 @@ export function startOfToday(): number {
* survives DST transitions; months/years clamp to the end of the target * survives DST transitions; months/years clamp to the end of the target
* month (Jan 31 + 1 month = Feb 28, not Mar 3). * month (Jan 31 + 1 month = Feb 28, not Mar 3).
*/ */
/** Strips a prefixed unit id ("milli:second") down to its base ("second"). */
function baseUnitId(unitId: string): string {
return unitId.includes(":") ? unitId.split(":")[1] : unitId;
}
export function addToDate(ms: number, amount: number, unitId: string): number { export function addToDate(ms: number, amount: number, unitId: string): number {
const baseId = baseUnitId(unitId); const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
const d = new Date(ms); const d = new Date(ms);
if ((baseId === "day" || baseId === "week") && Number.isInteger(amount)) { if ((baseId === "day" || baseId === "week") && Number.isInteger(amount)) {
d.setDate(d.getDate() + amount * (baseId === "week" ? 7 : 1)); d.setDate(d.getDate() + amount * (baseId === "week" ? 7 : 1));
@ -139,6 +122,6 @@ function lastDayOfMonth(d: Date): number {
} }
export function isCalendarUnit(unitId: string, amount: number): boolean { export function isCalendarUnit(unitId: string, amount: number): boolean {
const baseId = baseUnitId(unitId); const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount); return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount);
} }

View file

@ -13,10 +13,6 @@ function calc(expr: string): string | null {
return r[r.length - 1].text; return r[r.length - 1].text;
} }
function evaluateText(doc: string): (string | null)[] {
return eng.evaluateDocument(doc).map((x) => x.text);
}
describe("edge cases", () => { describe("edge cases", () => {
it("russian mixed sentence", () => { it("russian mixed sentence", () => {
expect(calc("потратил 20$ и ещё 5$")).toBe("$25"); expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
@ -49,11 +45,11 @@ describe("edge cases", () => {
expect(calc("hello world")).toBe(null); expect(calc("hello world")).toBe(null);
}); });
it("sum stops at header", () => { it("sum stops at header", () => {
const r = evaluateText("5\n# section\n10\n20\nsum"); const r = eng.evaluateDocument("5\n# section\n10\n20\nsum").map((x) => x.text);
expect(r).toEqual(["5", null, "10", "20", "30"]); expect(r).toEqual(["5", null, "10", "20", "30"]);
}); });
it("variables with units survive math", () => { it("variables with units survive math", () => {
const r = evaluateText("rent = $500\nfood = $200\nrent + food"); const r = eng.evaluateDocument("rent = $500\nfood = $200\nrent + food").map((x) => x.text);
expect(r).toEqual(["$500", "$200", "$700"]); expect(r).toEqual(["$500", "$200", "$700"]);
}); });
it("big numbers with grouping", () => { it("big numbers with grouping", () => {
@ -149,7 +145,7 @@ describe("crypto", () => {
describe("regressions", () => { describe("regressions", () => {
it("a broken line does not kill the document (BigInt from NaN)", () => { it("a broken line does not kill the document (BigInt from NaN)", () => {
const r = evaluateText("2+2\nasin(5) & 3\n3+3"); const r = eng.evaluateDocument("2+2\nasin(5) & 3\n3+3").map((x) => x.text);
expect(r).toEqual(["4", null, "6"]); expect(r).toEqual(["4", null, "6"]);
}); });
it("non-finite results are suppressed", () => { it("non-finite results are suppressed", () => {
@ -182,8 +178,8 @@ describe("regressions", () => {
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
}); });
it("single-letter unit names can be variables", () => { it("single-letter unit names can be variables", () => {
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]); expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]); expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
}); });
it("tiny conversion results show significant digits, not 0", () => { it("tiny conversion results show significant digits, not 0", () => {
expect(calc("1 mm in km")).toBe("0.000001 km"); expect(calc("1 mm in km")).toBe("0.000001 km");
@ -203,7 +199,7 @@ describe("regressions", () => {
expect(calc("4 ^ 50%")).toBe("2"); expect(calc("4 ^ 50%")).toBe("2");
}); });
it("avg skips incompatible lines in the denominator", () => { it("avg skips incompatible lines in the denominator", () => {
expect(evaluateText("10 kg\n20 kg\n5 hours\navg")).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]); expect(eng.evaluateDocument("10 kg\n20 kg\n5 hours\navg").map((x) => x.text)).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
}); });
it("calendar month add clamps to month end", () => { it("calendar month add clamps to month end", () => {
const doc = eng.evaluateDocument("today + 1 month"); const doc = eng.evaluateDocument("today + 1 month");
@ -211,119 +207,3 @@ describe("regressions", () => {
expect(v?.kind).toBe("date"); expect(v?.kind).toBe("date");
}); });
}); });
describe("review fixes", () => {
it("random() with no arguments returns a value in [0,1]", () => {
const r = calc("random()");
expect(r).not.toBeNull();
const v = Number(r);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
});
it("random() participates in arithmetic", () => {
expect(calc("random() + 1")).not.toBeNull();
});
it("multiplying same-dimension quantities converts the right operand", () => {
expect(calc("2 kg * 500 g")).toBe("1 kg");
expect(calc("$12 * 3")).toBe("$36");
expect(calc("2 m * 3 m")).toBe("6 m²");
});
it("exp(x) is Euler's number to the power x, not scientific notation", () => {
expect(calc("exp(1)")).toBe("2.72");
expect(calc("exp(0)")).toBe("1");
});
it("scientific notation still works via sci/exponent/exponential", () => {
expect(calc("123456 in scientific")).toBe("1.23e5");
expect(calc("123456 in exponent")).toBe("1.23e5");
});
it("timezone names with a slash resolve as IANA ids", () => {
expect(calc("time in Europe/Berlin")).toMatch(/^\d{2}:\d{2}$/);
});
it("a URL in a line is not mistaken for a comment", () => {
const r = eng.evaluateDocument("see https://example.com");
expect(r[0].commentStart).toBeNull();
});
it("an inline comment after whitespace still works", () => {
const r = eng.evaluateDocument("5 + 5 // note");
expect(r[0].text).toBe("10");
expect(r[0].commentStart).toBe(6);
});
it("invalid ISO date does not roll over — falls back to arithmetic", () => {
expect(calc("2026-13-45")).toBe("1,968");
});
describe("date literal formats", () => {
const dmyEng = new SumEngine({ dateFormat: "dmy" });
const mdyEng = new SumEngine({ dateFormat: "mdy" });
const isoEng = new SumEngine({ dateFormat: "iso" });
const calcWith = (e: SumEngine, expr: string) => {
const r = e.evaluateDocument(expr);
return r[r.length - 1].text;
};
it("dot-separated date respects the configured day/month order", () => {
expect(calcWith(dmyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
});
it("slash-separated date respects the configured day/month order", () => {
expect(calcWith(mdyEng, "12/31/2024 - 12/30/2024")).toBe("1 day");
});
it("ambiguous date (both parts <=12) follows the engine's dateFormat", () => {
// dmy: 01/02/2024 = 1 Feb; mdy: 01/02/2024 = 2 Jan
expect(calcWith(dmyEng, "01/02/2024 - 2024-01-01")).toBe("31 day");
expect(calcWith(mdyEng, "01/02/2024 - 2024-01-01")).toBe("1 day");
});
it("an unambiguous component (>12) wins regardless of dateFormat", () => {
expect(calcWith(mdyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
});
it("an invalid date (both components out of range) is not parsed as a date", () => {
const r = dmyEng.evaluateDocument("32.13.2024");
expect(r[0].value?.kind).not.toBe("date");
});
it("display format overrides the OS locale layout", () => {
expect(calcWith(isoEng, "2024-01-15")).toBe("2024-01-15");
expect(calcWith(dmyEng, "2024-01-15")).toBe("15.01.2024");
expect(calcWith(mdyEng, "2024-01-15")).toBe("01/15/2024");
});
});
});
describe("regression 2026-08-12", () => {
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
const r = evaluateText(`1+1\n${deep}\n2+2`);
expect(r[0]).toBe("2");
expect(r[1]).toBeNull();
expect(r[2]).toBe("4");
});
it("'5 min' means 5 minutes, not the min() aggregate", () => {
expect(calc("5 min")).toBe("5 min");
expect(calc("5 мин")).toBe("5 min");
});
it("standalone 'min'/'max' aggregate lines still work", () => {
const r = evaluateText("10\n20\n5\nmin");
expect(r).toEqual(["10", "20", "5", "5"]);
const r2 = evaluateText("10\n20\n5\nmax");
expect(r2).toEqual(["10", "20", "5", "20"]);
});
function unitIdOf(expr: string): string | null {
const v = eng.evaluateDocument(expr)[0].value;
return v?.kind === "quantity" ? v.unit?.id ?? null : null;
}
it("ambiguous currency aliases resolve to one deliberate currency; the other needs its full name", () => {
expect(unitIdOf("100 Manat")).toBe("AZN");
expect(unitIdOf("100 Turkmenistan Manat")).toBe("TMT");
expect(unitIdOf("100 манат")).toBe("AZN");
expect(unitIdOf("100 туркменский манат")).toBe("TMT");
expect(unitIdOf("100 FC")).toBe("CDF");
expect(unitIdOf("100 Comorian Franc")).toBe("KMF");
// "Som"/"Kwacha" go to the larger economy (UZS/ZMW); the smaller one needs its full name
expect(unitIdOf("100 Som")).toBe("UZS");
expect(unitIdOf("100 Kyrgyzstani Som")).toBe("KGS");
expect(unitIdOf("100 Kwacha")).toBe("ZMW");
expect(unitIdOf("100 Malawian Kwacha")).toBe("MWK");
expect(unitIdOf("100 квача")).toBe("ZMW");
expect(unitIdOf("100 малавийская квача")).toBe("MWK");
});
});

View file

@ -1,9 +1,5 @@
import { describe, it, expect, beforeAll } from "vitest"; import { describe, it, expect, beforeAll } from "vitest";
import { SumEngine } from "./index"; import { SumEngine } from "./index";
import { qty, EvalError, XRefError } from "./types";
import type { XRefResolution, EvalCtx } from "./evaluator";
import { evaluate } from "./evaluator";
import { formatValue } from "./formatter";
let eng: SumEngine; let eng: SumEngine;
@ -216,12 +212,6 @@ describe("numeral systems", () => {
}); });
describe("variables and document context", () => { describe("variables and document context", () => {
it("sequence error recovery", () => {
eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => {
throw new EvalError("test error");
});
expect(calc("throw_err(1) 2 hours")).toBe("2 h");
});
it("assignment and reuse", () => { it("assignment and reuse", () => {
expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]); expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]);
}); });
@ -241,33 +231,6 @@ describe("variables and document context", () => {
it("prev", () => { it("prev", () => {
expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]); expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]);
}); });
it("count", () => {
expect(calcDoc("10\n20\n30\ncount")).toEqual(["10", "20", "30", "3"]);
expect(calcDoc("10\n20\n\n30\ncount")).toEqual(["10", "20", null, "30", "1"]);
});
it("min / max", () => {
expect(calcDoc("10\n20\n5\nmin")).toEqual(["10", "20", "5", "5"]);
expect(calcDoc("10\n20\n5\nmax")).toEqual(["10", "20", "5", "20"]);
});
it("min / max with units", () => {
expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual(["10 m", "5 m", "20 m", "5 m"]);
expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual(["10 m", "5 m", "20 m", "20 m"]);
});
it("product", () => {
expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]);
});
it("random — deterministic per line text", () => {
const [a] = calcDoc("random(1, 6)");
const [b] = calcDoc("random(1, 6)");
expect(a).toBe(b); // same text → same seed → same result
const [c] = calcDoc("random(1, 6) ");
expect(c).not.toBe(a); // different text → different seed
});
it("random — stays in range", () => {
const v = Number(calc("random(1, 100)"));
expect(v).toBeGreaterThanOrEqual(1);
expect(v).toBeLessThanOrEqual(100);
});
it("headers split blocks and produce no result", () => { it("headers split blocks and produce no result", () => {
expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]); expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]);
}); });
@ -292,42 +255,6 @@ describe("dates", () => {
const t = calc("time in Tokyo"); const t = calc("time in Tokyo");
expect(t).toMatch(/^\d{2}:\d{2}$/); expect(t).toMatch(/^\d{2}:\d{2}$/);
}); });
it("ISO date literal produces a formatted date", () => {
const r = calc("2024-01-01");
expect(r).not.toBeNull();
expect(typeof r).toBe("string");
});
it("ISO date subtraction: 2026-01-01 - 2024-01-01 = 731 days", () => {
expect(calc("2026-01-01 - 2024-01-01")).toBe("731 day");
});
it("days until future date returns positive days", () => {
const r = calc("days until 2099-01-01");
expect(r).not.toBeNull();
expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0);
});
it("days since past date returns positive days", () => {
const r = calc("days since 2000-01-01");
expect(r).not.toBeNull();
expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0);
});
});
describe("historical rates", () => {
it("in EUR on date uses injected historical rate", () => {
eng.setHistoricalRates("2024-01-01", { EUR: 0.9, USD: 1 });
expect(calc("1000 USD in EUR on 2024-01-01")).toBe("€ 900");
});
it("missing historical date returns null", () => {
expect(calc("500 USD in EUR on 1800-01-01")).toBeNull();
});
});
describe("goal seek", () => {
it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33"));
it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150"));
it("100 - ? = 30", () => expect(calc("100 - ? = 30")).toBe("70"));
it("? / 4 = 5", () => expect(calc("? / 4 = 5")).toBe("20"));
it("? - ? = 5 has no solution", () => expect(calc("? - ? = 5")).toBeNull());
}); });
describe("extension API", () => { describe("extension API", () => {
@ -335,13 +262,6 @@ describe("extension API", () => {
eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 }); 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"); expect(calc("2 horses in meters")).toBe("4.8 m");
}); });
it("addUnit throws on id collision instead of silently corrupting unitsById", () => {
expect(() =>
eng.addUnit({ id: "horse", phrases: "nag", baseUnitId: "meter", format: "nag", ratio: 99 })
).toThrow(/horse/);
// the original registration must survive the failed collision
expect(calc("2 horses in meters")).toBe("4.8 m");
});
it("addFunction (zum from Sample.js)", () => { it("addFunction (zum from Sample.js)", () => {
eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double })); eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double }));
expect(calc("zum(2;3)")).toBe("5"); expect(calc("zum(2;3)")).toBe("5");
@ -351,104 +271,3 @@ describe("extension API", () => {
expect(calc("myvar * 2")).toBe("$10"); expect(calc("myvar * 2")).toBe("$10");
}); });
}); });
function calcWithResolver(
expr: string,
resolver: (sheet: string, key: string) => XRefResolution,
): string | null {
const r = eng.evaluateDocument(expr, resolver);
return r[r.length - 1].text;
}
describe("grand total", () => {
it("totalValueOf's raw value formats identically to totalOf's string", () => {
const results = eng.evaluateDocument("$100\n$50");
const raw = eng.totalValueOf(results);
expect(raw).not.toBeNull();
expect(formatValue(raw!, eng.settings)).toBe(eng.totalOf(results));
});
});
describe("cross-sheet references", () => {
function resolver(sheet: string, key: string): XRefResolution {
const s = sheet.toLowerCase();
if (s === "budget") {
if (key === "total") return { ok: true, value: qty(150) };
if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) };
if (key === "last") return { ok: true, value: qty(7) };
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
}
if (s === "trip to lisbon") {
if (key === "food") return { ok: true, value: qty(25) };
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
}
return { ok: false, reason: `sheet "${sheet}" not found` };
}
it("resolves @Sheet.total", () => {
expect(calcWithResolver("@Budget.total", resolver)).toBe("150");
});
it("resolves @Sheet.var and participates in arithmetic", () => {
expect(calcWithResolver("@Budget.rent + 1 m", resolver)).toBe("501 m");
});
it("resolves @Sheet.last", () => {
expect(calcWithResolver("@Budget.last", resolver)).toBe("7");
});
it("unit conversion over a resolved reference", () => {
expect(calcWithResolver("@Budget.rent in cm", resolver)).toBe("50,000 cm");
});
it("bracket form for titles with spaces", () => {
expect(calcWithResolver("@[Trip to Lisbon].food", resolver)).toBe("25");
});
it("unresolved sheet sets a line error instead of crashing", () => {
const r = eng.evaluateDocument("@Nope.total", resolver);
expect(r[0].value).toBeNull();
expect(r[0].error).toBe('sheet "Nope" not found');
});
it("unresolved key sets a line error", () => {
const r = eng.evaluateDocument("@Budget.nope", resolver);
expect(r[0].value).toBeNull();
expect(r[0].error).toBe('no variable "nope" in "Budget"');
});
it("ordinary successful line leaves LineResult.error undefined", () => {
const r = eng.evaluateDocument("$100", resolver);
expect(r[0].error).toBeUndefined();
});
it("propagates a resolver-reported circular reference", () => {
const cyc = (): XRefResolution => ({ ok: false, reason: "circular reference" });
const r = eng.evaluateDocument("@A.total", cyc);
expect(r[0].value).toBeNull();
expect(r[0].error).toBe("circular reference");
});
it("no resolver given: resolves to null without throwing", () => {
expect(() => eng.evaluateDocument("@Budget.total")).not.toThrow();
const r = eng.evaluateDocument("@Budget.total");
expect(r[0].value).toBeNull();
expect(r[0].error).toBe('sheet "Budget" not found');
});
it("an assignment line reports the assigned name via LineResult.assign", () => {
const r = eng.evaluateDocument("rent = $500");
expect(r[0].assign).toBe("rent");
});
});
describe("evaluation errors", () => {
it("throws EvalError for unknown variable", () => {
const ctx = {
vars: new Map(),
reg: eng.reg,
historicalRates: {},
} as unknown as EvalCtx;
expect(() => evaluate({ k: "var", name: "unknown" }, ctx)).toThrowError(EvalError);
});
it("throws XRefError for unresolved xref", () => {
const ctx = {
vars: new Map(),
reg: eng.reg,
historicalRates: {},
resolveXRef: () => ({ ok: false, reason: "xref error" }),
} as unknown as EvalCtx;
expect(() => evaluate({ k: "xref", sheet: "Sheet", key: "key" }, ctx)).toThrowError(XRefError);
});
});

View file

@ -1,90 +0,0 @@
import { describe, it, expect } from "vitest";
import { toBase, fromBase, convertQ, attachUnit } from "./evaluator";
import { qty, pct, EvalError, Decimal } from "./types";
import { buildRegistry } from "./registry";
const reg = buildRegistry();
const getUnit = (id: string) => {
const u = reg.unitsById.get(id);
if (!u) throw new Error(`unit ${id} not found`);
return u;
};
describe("quantity conversion", () => {
it("toBase works", () => {
// 1 km = 1000 m
const km = getUnit("kilo:meter");
const res1 = toBase(qty(2, km));
expect(res1.toNumber()).toBe(2000);
// 0 C = 273.15 K
const c = getUnit("celsius");
const res2 = toBase(qty(0, c));
expect(res2.toNumber()).toBe(273.15);
// 10 mpg
const mpg = getUnit("mpg");
const res3 = toBase(qty(10, mpg));
expect(res3.toNumber()).toBeCloseTo(23.5215, 4);
// error on zero for reciprocal
expect(() => toBase(qty(0, mpg))).toThrow(EvalError);
});
it("fromBase works", () => {
const km = getUnit("kilo:meter");
const res1 = fromBase(new Decimal(2000), km);
expect(res1.toNumber()).toBe(2);
const c = getUnit("celsius");
const res2 = fromBase(new Decimal(273.15), c);
expect(res2.toNumber()).toBe(0);
const mpg = getUnit("mpg");
const res3 = fromBase(new Decimal(23.5215), mpg);
expect(res3.toNumber()).toBeCloseTo(10, 4);
expect(() => fromBase(new Decimal(0), mpg)).toThrow(EvalError);
});
it("convertQ works", () => {
const m = getUnit("meter");
const km = getUnit("kilo:meter");
// 2000m -> km
const q1 = convertQ(qty(2000, m), km);
expect(q1.value.toNumber()).toBe(2);
expect(q1.unit?.id).toBe("kilo:meter");
// c -> f
const c = getUnit("celsius");
const f = getUnit("fahrenheit");
const q2 = convertQ(qty(0, c), f);
expect(q2.value.toNumber()).toBeCloseTo(32);
// fail on mismatched dimensions
const kg = getUnit("kilo:gram");
expect(() => convertQ(qty(1, m), kg)).toThrow(EvalError);
});
it("attachUnit works", () => {
const m = getUnit("meter");
const km = getUnit("kilo:meter");
const kg = getUnit("kilo:gram");
// attaching to dimensionless
const r1 = attachUnit(qty(2), m);
expect(r1).toMatchObject({ kind: "quantity", unit: { id: "meter" } });
// attaching matching dimension does conversion
const r2 = attachUnit(qty(2000, m), km);
expect((r2 as any).value.toNumber()).toBe(2);
expect((r2 as any).unit?.id).toBe("kilo:meter");
// attaching mismatched dimension throws
expect(() => attachUnit(qty(1, m), kg)).toThrow(EvalError);
// attaching to non-quantity throws
expect(() => attachUnit(pct(50), m)).toThrow(EvalError);
});
});

View file

@ -1,14 +1,9 @@
// Walks the AST with the document context (variables, line results for // Walks the AST with the document context (variables, line results for
// sum/avg/prev) and produces unit-aware values. // sum/avg/prev) and produces unit-aware values.
import { Decimal, DEC_ZERO, DEC_ONE, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types"; import { Decimal, EvalError, Quantity, Unit, Value, qty, pct } from "./types";
import { Node, ConvTarget } from "./parser"; import { Node, ConvTarget } from "./parser";
import { Registry } from "./registry"; import { Registry } from "./registry";
import { import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
resolveZone,
startOfToday,
addToDate,
isCalendarUnit,
} from "./datetime";
export interface LineCtx { export interface LineCtx {
/** values of lines above (null = no result) */ /** values of lines above (null = no result) */
@ -16,25 +11,15 @@ export interface LineCtx {
/** per-line flags for block boundaries */ /** per-line flags for block boundaries */
lineKinds: ("empty" | "header" | "normal")[]; lineKinds: ("empty" | "header" | "normal")[];
index: number; index: number;
/** raw line text — used to seed random() deterministically */
lineText: string;
} }
export interface EvalCtx { export interface EvalCtx {
reg: Registry; reg: Registry;
vars: Map<string, Value>; vars: Map<string, Value>;
line: LineCtx; line: LineCtx;
/** date → { code → rate-per-usd } */
historicalRates?: Map<string, Map<string, number>>;
/** cross-sheet `@Sheet.key` lookup; absent outside the app (tests, extensions) */
resolveXRef?: XRefResolver;
} }
export type XRefResolution = const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
| { ok: true; value: Value }
| { ok: false; reason: string };
export type XRefResolver = (sheet: string, key: string) => XRefResolution;
const E = new Decimal("2.71828182845904523536028747135266249775724709369995"); const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
export function evaluate(node: Node, ctx: EvalCtx): Value { export function evaluate(node: Node, ctx: EvalCtx): Value {
@ -42,34 +27,55 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "num": case "num":
return qty(node.v, null, node.repr); return qty(node.v, null, node.repr);
case "const": case "const":
return evaluateConst(node); switch (node.name) {
case "var": case "pi": return qty(PI);
case "xref": case "e": return qty(E);
case "unknown": case "half": return qty(new Decimal("0.5"));
return evaluateRef(node, ctx); case "onehalf": return qty(new Decimal("1.5"));
}
case "var": {
const v = ctx.vars.get(node.name);
if (!v) throw new EvalError(`unknown variable ${node.name}`);
return v;
}
case "date": case "date":
return evalDateWord(node.word); return evalDateWord(node.word);
case "datelit":
return { kind: "date", ms: node.ms, hasTime: false };
case "goalseek":
return evalGoalSeek(node.lhs, node.rhs, ctx);
case "agg": case "agg":
return evalAgg(node.name, ctx); return evalAgg(node.name, ctx);
case "neg": case "neg": {
case "pct": const v = evaluate(node.x, ctx);
case "fact": if (v.kind === "quantity") return { ...v, value: v.value.neg() };
case "scale": if (v.kind === "percent") return pct(v.value.neg());
return evaluateMath(node, ctx); throw new EvalError("cannot negate a date");
case "unit": }
case "curr": case "pct": {
return evaluateUnitMod(node, ctx); 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": case "bin":
return evalBin( return evalBin(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx), ctx);
node.op,
evaluate(node.l, ctx),
evaluate(node.r, ctx),
ctx,
);
case "bit": case "bit":
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx)); return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
case "pctop": case "pctop":
@ -77,13 +83,30 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "conv": case "conv":
return evalConv(evaluate(node.x, ctx), node.target, ctx); return evalConv(evaluate(node.x, ctx), node.target, ctx);
case "call": case "call":
return evalCall( return evalCall(node.name, node.args.map((a) => evaluate(a, ctx)), ctx);
node.name, case "seq": {
node.args.map((a) => evaluate(a, ctx)), let acc: Value | null = null;
ctx, for (const item of node.items) {
); let v: Value;
case "seq": try {
return evaluateSeq(node, ctx); 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;
}
} }
} }
@ -95,7 +118,7 @@ function asScalar(v: Value): Decimal {
throw new EvalError("expected a number"); throw new EvalError("expected a number");
} }
export function attachUnit(v: Value, unit: Unit): Value { function attachUnit(v: Value, unit: Unit): Value {
if (v.kind === "quantity") { if (v.kind === "quantity") {
if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit); if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit);
if (v.unit) throw new EvalError("unit mismatch"); if (v.unit) throw new EvalError("unit mismatch");
@ -128,8 +151,7 @@ export function fromBase(base: Decimal, unit: Unit): Decimal {
export function convertQ(q: Quantity, unit: Unit): Quantity { export function convertQ(q: Quantity, unit: Unit): Quantity {
if (!q.unit) return { ...q, unit }; if (!q.unit) return { ...q, unit };
if (q.unit.dimension !== unit.dimension) if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
throw new EvalError("dimension mismatch");
return qty(fromBase(toBase(q), unit), unit); return qty(fromBase(toBase(q), unit), unit);
} }
@ -139,77 +161,59 @@ function addQ(a: Quantity, b: Quantity): Quantity {
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity { function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
if (a.unit && b.unit) { if (a.unit && b.unit) {
if (a.unit.dimension !== b.unit.dimension) if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
throw new EvalError("dimension mismatch");
if (a.unit.offset || b.unit.offset) { if (a.unit.offset || b.unit.offset) {
// temperatures: operate in the left unit's scale // temperatures: operate in the left unit's scale
const bInA = convertQ(b, a.unit); const bInA = convertQ(b, a.unit);
return qty( return qty(sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value), a.unit);
sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value),
a.unit,
);
} }
const base = const base = sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
return qty(fromBase(base, a.unit), a.unit); return qty(fromBase(base, a.unit), a.unit);
} }
const unit = a.unit ?? b.unit ?? null; const unit = a.unit ?? b.unit ?? null;
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit); return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
} }
function evalPercentArith( function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value, ctx: EvalCtx): Value {
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", // ---- dates
l: Value, if (l.kind === "date" || r.kind === "date") return evalDateArith(op, l, r);
r: Value,
): Value { // ---- percent combinations
if (l.kind === "percent" && r.kind === "percent") { 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) { switch (op) {
case "plus": case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
return pct(l.value.add(r.value));
case "minus": case "minus":
return pct(l.value.sub(r.value)); if (l.kind === "percent") throw new EvalError("percent minus number");
case "mul": return qty(q.value.mul(new Decimal(1).sub(p.div(100))), q.unit);
return pct(l.value.mul(r.value).div(100)); case "mul": return qty(q.value.mul(p).div(100), q.unit);
case "div": case "div":
return qty(l.value.div(r.value)); if (l.kind === "percent") throw new EvalError("percent div number");
default: return qty(q.value.div(p.div(100)), q.unit);
throw new EvalError("bad percent op"); case "pow":
// which side the percent is on matters: 50% ^ 2 = 0.25, 2 ^ 50% = √2
return l.kind === "percent"
? qty(p.div(100).pow(q.value))
: qty(q.value.pow(p.div(100)), q.unit);
default: throw new EvalError("bad percent op");
} }
} }
const q = (l.kind === "quantity" ? l : r) as Quantity;
const p = (
l.kind === "percent" ? l : (r as { kind: "percent"; value: Decimal })
).value;
switch (op) {
case "plus": return qty(q.value.mul(DEC_ONE.add(p.div(100))), q.unit);
case "minus":
if (l.kind === "percent") throw new EvalError("percent minus number");
return qty(q.value.mul(DEC_ONE.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":
// which side the percent is on matters: 50% ^ 2 = 0.25, 2 ^ 50% = √2
return l.kind === "percent"
? qty(p.div(100).pow(q.value))
: qty(q.value.pow(p.div(100)), q.unit);
default:
throw new EvalError("bad percent op");
}
}
function evalQuantityArith( const a = l as Quantity;
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", const b = r as Quantity;
a: Quantity,
b: Quantity,
ctx: EvalCtx,
): Value {
switch (op) { switch (op) {
case "plus": case "plus": return numericAdd(a, b, 1);
return numericAdd(a, b, 1); case "minus": return numericAdd(a, b, -1);
case "minus":
return numericAdd(a, b, -1);
case "mul": { case "mul": {
if (a.unit && b.unit) { if (a.unit && b.unit) {
const da = a.unit.dimension; const da = a.unit.dimension;
@ -223,9 +227,6 @@ function evalQuantityArith(
const u = ctx.reg.unitsById.get(targetId); const u = ctx.reg.unitsById.get(targetId);
if (u) return qty(baseProduct.div(u.ratio), u); if (u) return qty(baseProduct.div(u.ratio), u);
} }
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
if (da === db)
return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
return qty(a.value.mul(b.value), a.unit); return qty(a.value.mul(b.value), a.unit);
} }
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null); return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
@ -244,77 +245,34 @@ function evalQuantityArith(
case "pow": { case "pow": {
const result = a.value.pow(b.value); const result = a.value.pow(b.value);
if (a.unit?.dimension === "length" && !b.unit) { if (a.unit?.dimension === "length" && !b.unit) {
const id = b.value.eq(2) const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null;
? `sq:${a.unit.id}`
: b.value.eq(3)
? `cb:${a.unit.id}`
: null;
const u = id ? ctx.reg.unitsById.get(id) : null; const u = id ? ctx.reg.unitsById.get(id) : null;
if (u) return qty(result, u); if (u) return qty(result, u);
} }
return qty( return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null);
result,
b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null,
);
} }
} }
} }
function evalBin(
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
l: Value,
r: Value,
ctx: EvalCtx,
): Value {
if (l.kind === "date" || r.kind === "date") {
return evalDateArith(op, l, r);
}
if (l.kind === "percent" || r.kind === "percent") {
return evalPercentArith(op, l, r);
}
return evalQuantityArith(op, l as Quantity, r as Quantity, ctx);
}
function evalBit(op: string, l: Value, r: Value): Value { function evalBit(op: string, l: Value, r: Value): Value {
if (l.kind !== "quantity" || r.kind !== "quantity") if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
throw new EvalError("bitwise needs integers"); if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
if (!l.value.isFinite() || !r.value.isFinite())
throw new EvalError("bitwise needs finite integers");
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0)); const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0)); const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
throw new EvalError("bad shift");
let out: bigint; let out: bigint;
switch (op) { switch (op) {
case "band": case "band": out = a & b; break;
out = a & b; case "bor": out = a | b; break;
break; case "bxor": out = a ^ b; break;
case "bor": case "shl": out = a << b; break;
out = a | b; case "shr": out = a >> b; break;
break; default: throw new EvalError("bad bit op");
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; const repr = l.repr !== "decimal" ? l.repr : r.repr;
return qty(new Decimal(out.toString()), null, repr); return qty(new Decimal(out.toString()), null, repr);
} }
/** l already a percent -> its value; otherwise treat the scalar as a whole-number percent. */
function asPercentValue(l: Value): Decimal {
return l.kind === "percent" ? l.value : asScalar(l).mul(100);
}
function evalPctOp(op: string, l: Value, r: Value): Value { function evalPctOp(op: string, l: Value, r: Value): Value {
const rq = r.kind === "quantity" ? r : null; const rq = r.kind === "quantity" ? r : null;
const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null; const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null;
@ -322,11 +280,11 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
const unit = rq?.unit ?? null; const unit = rq?.unit ?? null;
if (op === "of" || op === "off" || op === "on") { if (op === "of" || op === "off" || op === "on") {
const p = asPercentValue(l); const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
const factor = const factor =
op === "of" ? p.div(100) op === "of" ? p.div(100)
: op === "off" ? DEC_ONE.sub(p.div(100)) : op === "off" ? new Decimal(1).sub(p.div(100))
: DEC_ONE.add(p.div(100)); : new Decimal(1).add(p.div(100));
// a percent of a percent stays a percent: 50% of 50% = 25% // a percent of a percent stays a percent: 50% of 50% = 25%
if (r.kind === "percent") return pct(r.value.mul(factor)); if (r.kind === "percent") return pct(r.value.mul(factor));
return qty(rv.mul(factor), unit); return qty(rv.mul(factor), unit);
@ -336,21 +294,20 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
if (rv.isZero()) throw new EvalError("division by zero"); if (rv.isZero()) throw new EvalError("division by zero");
switch (op) { switch (op) {
case "as_pct_of": return pct(lv.div(rv).mul(100)); case "as_pct_of": return pct(lv.div(rv).mul(100));
case "as_pct_off": return pct(DEC_ONE.sub(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)); case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
} }
} }
// "20% of what is 30" // "20% of what is 30"
const p = asPercentValue(l); const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
switch (op) { switch (op) {
case "of_what_is": case "of_what_is": return qty(rv.div(p.div(100)), unit);
return qty(rv.div(p.div(100)), unit);
case "off_what_is": { case "off_what_is": {
const d = DEC_ONE.sub(p.div(100)); const d = new Decimal(1).sub(p.div(100));
if (d.isZero()) throw new EvalError("division by zero"); if (d.isZero()) throw new EvalError("division by zero");
return qty(rv.div(d), unit); return qty(rv.div(d), unit);
} }
case "on_what_is": return qty(rv.div(DEC_ONE.add(p.div(100))), unit); case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
} }
throw new EvalError(`bad percent op ${op}`); throw new EvalError(`bad percent op ${op}`);
} }
@ -359,27 +316,14 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
switch (target.type) { switch (target.type) {
case "unit": { case "unit": {
if (v.kind === "percent") return pct(v.value); // "x in %" — keep if (v.kind === "percent") return pct(v.value); // "x in %" — keep
if (v.kind !== "quantity") if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit");
throw new EvalError("cannot convert date to unit");
if (!v.unit) return qty(v.value, target.unit); if (!v.unit) return qty(v.value, target.unit);
return convertQ(v, target.unit); return convertQ(v, target.unit);
} }
case "currency": { case "currency": {
let unit; const unit = ctx.reg.makeCurrencyUnit(target.code);
if (target.onDate) { if (!unit) throw new EvalError(`no rate for ${target.code}`);
const dateRates = ctx.historicalRates?.get(target.onDate); if (v.kind !== "quantity") throw new EvalError("cannot convert to currency");
const rate = dateRates?.get(target.code);
if (rate === undefined)
throw new EvalError(
`no historical rate for ${target.code} on ${target.onDate}`,
);
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
} else {
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); if (!v.unit) return qty(v.value, unit);
return convertQ(v, unit); return convertQ(v, unit);
} }
@ -398,17 +342,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
return { ...v, timeZone: zone }; return { ...v, timeZone: zone };
} }
case "unix": { case "unix": {
if (v.kind === "date") if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
throw new EvalError("unix needs a date"); throw new EvalError("unix needs a date");
} }
case "todate": { case "todate": {
if (v.kind !== "quantity" || v.unit) if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number");
throw new EvalError("date needs a plain number");
// heuristics: > 1e12 means milliseconds, else seconds // heuristics: > 1e12 means milliseconds, else seconds
const ms = v.value.gt(1e12) const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber();
? v.value.toNumber()
: v.value.mul(1000).toNumber();
return { kind: "date", ms, hasTime: true }; return { kind: "date", ms, hasTime: true };
} }
} }
@ -416,25 +356,12 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
function evalDateWord(word: string): Value { function evalDateWord(word: string): Value {
switch (word) { switch (word) {
case "today": case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
return { kind: "date", ms: startOfToday(), hasTime: false }; case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false };
case "tomorrow": { case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, hasTime: false };
const d = new Date(startOfToday()); case "now": return { kind: "date", ms: Date.now(), hasTime: true };
d.setDate(d.getDate() + 1); case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
return { kind: "date", ms: d.getTime(), hasTime: false }; default: throw new EvalError(`bad date word ${word}`);
}
case "yesterday": {
const d = new Date(startOfToday());
d.setDate(d.getDate() - 1);
return { kind: "date", ms: d.getTime(), 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}`);
} }
} }
@ -442,16 +369,10 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
if (l.kind === "date" && r.kind === "date") { if (l.kind === "date" && r.kind === "date") {
if (op !== "minus") throw new EvalError("dates only subtract"); if (op !== "minus") throw new EvalError("dates only subtract");
// midnight-to-midnight differences count calendar days (robust across DST) // midnight-to-midnight differences count calendar days (robust across DST)
const days = const days = !l.hasTime && !r.hasTime
!l.hasTime && !r.hasTime ? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
? new Decimal(Math.round((l.ms - r.ms) / 86400_000)) : new Decimal(l.ms - r.ms).div(86400_000);
: new Decimal(l.ms - r.ms).div(86400_000); return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
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 date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
const span = l.kind === "date" ? r : l; const span = l.kind === "date" ? r : l;
@ -459,8 +380,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
throw new EvalError("date arithmetic needs a time span"); throw new EvalError("date arithmetic needs a time span");
} }
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op"); if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
if (op === "minus" && l.kind !== "date") if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
throw new EvalError("cannot subtract date from number");
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1); const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
let ms: number; let ms: number;
let hasTime = date.hasTime; let hasTime = date.hasTime;
@ -475,150 +395,15 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
return { ...date, ms, hasTime }; return { ...date, ms, hasTime };
} }
function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value { function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value {
const evalF = (x: Decimal): Decimal => {
const vars = new Map(ctx.vars);
vars.set("__x__", qty(x));
const innerCtx: EvalCtx = { ...ctx, vars };
const lv = evaluate(lhsNode, innerCtx);
const rv = evaluate(rhsNode, innerCtx);
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : DEC_ZERO;
return toD(lv).minus(toD(rv));
};
const safeEval = (x: Decimal): Decimal | null => {
try {
return evalF(x);
} catch {
return null;
}
};
const f0 = safeEval(DEC_ZERO);
const f1 = safeEval(DEC_ONE);
if (f0 !== null && f1 !== null) {
const slope = f1.minus(f0);
if (!slope.isZero()) {
const root = f0.neg().div(slope);
const fRoot = safeEval(root);
if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9")))
return qty(root);
}
}
// Bisection fallback for nonlinear cases
let lo = new Decimal(-1e9);
let hi = new Decimal(1e9);
let flo = safeEval(lo);
let fhi = safeEval(hi);
if (flo === null || fhi === null || flo.mul(fhi).gt(0)) {
const probes = [
1, 2, 5, 10, 100, 1000, 10000, -1, -2, -5, -10, -100, -1000, -10000,
];
let found = false;
for (const p of probes) {
const pDec = new Decimal(p);
const fp = safeEval(pDec);
if (fp === null) continue;
if (flo === null) {
lo = pDec;
flo = fp;
} else if (fhi === null) {
hi = pDec;
fhi = fp;
} else if (flo.mul(fp).lte(0)) {
hi = pDec;
fhi = fp;
} else if (fhi.mul(fp).lte(0)) {
lo = pDec;
flo = fp;
} else continue;
if (flo !== null && fhi !== null && flo.mul(fhi).lte(0)) {
found = true;
break;
}
}
if (!found) throw new EvalError("no solution");
}
for (let i = 0; i < 100; i++) {
const mid = lo.plus(hi).div(2);
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
const fm = safeEval(mid);
if (fm === null) {
lo = mid.plus("1e-9");
continue;
}
if (flo!.mul(fm).lte(0)) {
hi = mid;
fhi = fm;
} else {
lo = mid;
flo = fm;
}
}
return qty(lo.plus(hi).div(2));
}
/** Lines whose unit shares the first line's dimension (mixed-dimension blocks skip the rest). */
function filterSameDimension(block: Quantity[]): Quantity[] {
const refDim = block[0].unit?.dimension ?? null;
return block.filter((q) => (q.unit?.dimension ?? null) === refDim);
}
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */
function reduceCompatible(block: Quantity[]): {
acc: Quantity;
counted: number;
} {
let acc = block[0];
let counted = 1;
for (let i = 1; i < block.length; i++) {
try {
acc = numericAdd(acc, block[i], 1);
counted++;
} catch {
// skip lines with incompatible dimensions
}
}
return { acc, counted };
}
const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
count: (block) => qty(new Decimal(block.length)),
chart: (block) => {
const compatible = filterSameDimension(block);
if (compatible.length < 2)
throw new EvalError("need at least 2 values for chart");
return {
kind: "chart",
points: compatible.map((q) => toBase(q)),
unitLabel: block[0].unit?.format ?? null,
};
},
min: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).lt(toBase(best)) ? q : best),
max: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).gt(toBase(best)) ? q : best),
product: (block) => qty(block.reduce((acc, q) => acc.mul(q.value), DEC_ONE)),
sum: (block) => reduceCompatible(block).acc,
avg: (block) => {
const { acc, counted } = reduceCompatible(block);
return qty(acc.value.div(counted), acc.unit);
},
};
function getPrevResult(ctx: EvalCtx): Value {
const { lineValues, index } = ctx.line;
for (let i = index - 1; i >= 0; i--) {
const v = lineValues[i];
if (v) return v;
}
throw new EvalError("no previous result");
}
function getAggBlock(ctx: EvalCtx): Quantity[] {
const { lineValues, lineKinds, index } = ctx.line; 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[] = []; const block: Quantity[] = [];
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
if (lineKinds[i] === "empty" || lineKinds[i] === "header") { if (lineKinds[i] === "empty" || lineKinds[i] === "header") {
@ -631,250 +416,72 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
} }
if (block.length === 0) throw new EvalError("nothing to aggregate"); if (block.length === 0) throw new EvalError("nothing to aggregate");
block.reverse(); block.reverse();
return block; let acc = block[0];
} let counted = 1;
for (let i = 1; i < block.length; i++) {
function evalAgg( const item = block[i];
name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", try {
ctx: EvalCtx, acc = numericAdd(acc, item, 1);
): Value { counted++;
if (name === "prev") return getPrevResult(ctx); } catch {
const block = getAggBlock(ctx); // skip lines with incompatible dimensions
const func = AGG_FUNCS[name];
if (!func) throw new EvalError(`unknown aggregate function ${name}`);
return func(block);
}
const angleArg = (x: Value): Decimal => {
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const BUILTIN_FUNCS: Record<string, (x: Value, ctx: EvalCtx) => Value> = {
sqrt: (x, ctx) => {
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(asScalar(x).sqrt()); }
}, if (name === "avg") return qty(acc.value.div(counted), acc.unit);
cbrt: (x) => qty(asScalar(x).cbrt()), return acc;
sin: (x) => qty(Decimal.sin(angleArg(x))), }
cos: (x) => qty(Decimal.cos(angleArg(x))),
tan: (x) => qty(Decimal.tan(angleArg(x))),
cot: (x) => qty(DEC_ONE.div(Decimal.tan(angleArg(x)))),
asin: (x) => qty(Decimal.asin(asScalar(x))),
arcsin: (x) => qty(Decimal.asin(asScalar(x))),
acos: (x) => qty(Decimal.acos(asScalar(x))),
arccos: (x) => qty(Decimal.acos(asScalar(x))),
atan: (x) => qty(Decimal.atan(asScalar(x))),
arctan: (x) => qty(Decimal.atan(asScalar(x))),
sinh: (x) => qty(Decimal.sinh(asScalar(x))),
cosh: (x) => qty(Decimal.cosh(asScalar(x))),
tanh: (x) => qty(Decimal.tanh(asScalar(x))),
ln: (x) => qty(Decimal.ln(asScalar(x))),
lg: (x) => qty(Decimal.log10(asScalar(x))),
log: (x) => qty(Decimal.log10(asScalar(x))),
log2: (x) => qty(Decimal.log2(asScalar(x))),
exp: (x) => qty(Decimal.exp(asScalar(x))),
abs: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.abs() }
: qty(asScalar(x).abs()),
round: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.round() }
: qty(asScalar(x).round()),
ceil: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.ceil() }
: qty(asScalar(x).ceil()),
floor: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.floor() }
: qty(asScalar(x).floor()),
fact: (x) => qty(factorial(asScalar(x))),
factorial: (x) => qty(factorial(asScalar(x))),
until: (x) => {
if (x.kind !== "date") throw new EvalError("until needs a date");
return evalDateArith("minus", x, {
kind: "date",
ms: startOfToday(),
hasTime: false,
});
},
since: (x) => {
if (x.kind !== "date") throw new EvalError("since needs a date");
return evalDateArith(
"minus",
{ kind: "date", ms: startOfToday(), hasTime: false },
x,
);
},
};
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
const custom = ctx.reg.customFuncs.get(name); const custom = ctx.reg.customFuncs.get(name);
if (custom) return custom(args); if (custom) return custom(args);
if (name === "random") return evalRandom(args, ctx); // random() is valid with no args
const x = args[0]; const x = args[0];
if (!x) throw new EvalError(`${name} needs an argument`); if (!x) throw new EvalError(`${name} needs an argument`);
const func = BUILTIN_FUNCS[name]; // trig works on angles: degrees convert to radians
if (!func) throw new EvalError(`unknown function ${name}`); const angleArg = (): Decimal => {
return func(x, ctx); if (x.kind === "quantity" && x.unit?.dimension === "angle") {
} return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const n = () => asScalar(x);
// Native float, not Decimal: this is a PRNG (mulberry32/strHash bit-twiddling switch (name) {
// over 32-bit ints), not arithmetic on user quantities — decimal.js has no case "sqrt": {
// bitwise ops and would add nothing but overhead here. if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
function evalRandom(args: Value[], ctx: EvalCtx): Value { const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9); if (base) return qty(x.value.sqrt(), base);
const rand = mulberry32(seed); }
if (args.length === 0) return qty(new Decimal(rand)); return qty(n().sqrt());
const toNum = (v: Value) => (v.kind === "quantity" ? v.value.toNumber() : 0); }
const [lo, hi] = case "cbrt": return qty(n().cbrt());
args.length === 1 case "sin": return qty(Decimal.sin(angleArg()));
? [0, toNum(args[0]!)] case "cos": return qty(Decimal.cos(angleArg()));
: [toNum(args[0]!), toNum(args[1]!)]; case "tan": return qty(Decimal.tan(angleArg()));
const isInt = Number.isInteger(lo) && Number.isInteger(hi); case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
const raw = isInt case "asin": case "arcsin": return qty(Decimal.asin(n()));
? Math.floor(rand * (hi - lo + 1)) + lo case "acos": case "arccos": return qty(Decimal.acos(n()));
: rand * (hi - lo) + lo; case "atan": case "arctan": return qty(Decimal.atan(n()));
return qty(new Decimal(raw)); case "sinh": return qty(Decimal.sinh(n()));
} case "cosh": return qty(Decimal.cosh(n()));
case "tanh": return qty(Decimal.tanh(n()));
function strHash(s: string): number { case "ln": return qty(Decimal.ln(n()));
let h = 0x811c9dc5; case "lg": case "log": return qty(Decimal.log10(n()));
for (let i = 0; i < s.length; i++) { case "log2": return qty(Decimal.log2(n()));
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193); 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}`);
} }
return h >>> 0;
}
function mulberry32(seed: number): number {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
} }
function factorial(n: Decimal): Decimal { function factorial(n: Decimal): Decimal {
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial"); if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
let acc = DEC_ONE; let acc = new Decimal(1);
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i); for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
return acc; return acc;
} }
function evaluateConst(node: {
k: "const";
name: "pi" | "e" | "half" | "onehalf";
}): Value {
switch (node.name) {
case "pi":
return qty(PI);
case "e":
return qty(E);
case "half":
return qty(new Decimal("0.5"));
case "onehalf":
return qty(new Decimal("1.5"));
}
}
function evaluateRef(node: Node, ctx: EvalCtx): Value {
if (node.k === "var") {
const v = ctx.vars.get(node.name);
if (!v) throw new EvalError(`unknown variable ${node.name}`);
return v;
}
if (node.k === "xref") {
const res = ctx.resolveXRef?.(node.sheet, node.key);
if (!res || !res.ok) {
throw new XRefError(
res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`,
);
}
return res.value;
}
if (node.k === "unknown") {
const xv = ctx.vars.get("__x__");
if (!xv) throw new EvalError("? used outside goal seek");
return xv;
}
throw new EvalError("invalid ref node");
}
function evaluateMath(node: Node, ctx: EvalCtx): Value {
if (node.k === "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");
}
if (node.k === "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");
}
if (node.k === "fact") {
const v = asScalar(evaluate(node.x, ctx));
return qty(factorial(v));
}
if (node.k === "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");
}
throw new EvalError("invalid math node");
}
function evaluateUnitMod(node: Node, ctx: EvalCtx): Value {
if (node.k === "unit") {
const v = evaluate(node.x, ctx);
return attachUnit(v, node.unit);
}
if (node.k === "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);
}
throw new EvalError("invalid unit mod node");
}
function evaluateSeq(node: { k: "seq"; items: Node[] }, ctx: EvalCtx): Value {
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;
}

View file

@ -1,88 +0,0 @@
import { describe, it, expect } from "vitest";
import { formatNumber, formatValue } from "./formatter";
import { Decimal, defaultSettings, qty, pct, DateVal, ChartValue, EngineSettings } from "./types";
describe("formatNumber", () => {
it("formats standard numbers", () => {
expect(formatNumber(new Decimal("12.345"), defaultSettings)).toBe("12.35");
expect(formatNumber(new Decimal("100"), defaultSettings)).toBe("100");
});
it("handles custom group separators", () => {
const s: EngineSettings = { ...defaultSettings, groupSeparator: " " };
expect(formatNumber(new Decimal("1234567.89"), s)).toBe("1 234 567.89");
});
it("handles custom decimal separators", () => {
const s: EngineSettings = { ...defaultSettings, decimalSeparator: "," };
expect(formatNumber(new Decimal("12.345"), s)).toBe("12,35");
});
it("handles scientific notation strings automatically", () => {
expect(formatNumber(new Decimal("1e25"), defaultSettings)).toBe("1e+25");
});
it("applies significant digits fallback for numbers close to zero", () => {
// 0.001 rounded to 0 decimals is 0, but since >= 1e-12, it shows significant digits
const s: EngineSettings = { ...defaultSettings, precision: 0 };
expect(formatNumber(new Decimal("0.001"), s)).toBe("0.001");
});
it("treats numbers < 1e-12 as conversion noise", () => {
const s: EngineSettings = { ...defaultSettings, precision: 2 };
expect(formatNumber(new Decimal("1e-13"), s)).toBe("0");
});
});
describe("formatValue", () => {
it("formats chart as empty string", () => {
const v: ChartValue = { kind: "chart", points: [], unitLabel: null };
expect(formatValue(v, defaultSettings)).toBe("");
});
it("formats percent values", () => {
expect(formatValue(pct(25.5), defaultSettings)).toBe("25.5%");
});
it("formats dates depending on flags", () => {
// 2024-01-01 12:00:00 UTC = 1704110400000
const d: DateVal = { kind: "date", ms: 1704110400000, hasTime: false, timeZone: "UTC" };
expect(formatValue(d, { ...defaultSettings, dateFormat: "iso" })).toBe("2024-01-01");
expect(formatValue(d, { ...defaultSettings, dateFormat: "dmy" })).toBe("01.01.2024");
expect(formatValue(d, { ...defaultSettings, dateFormat: "mdy" })).toBe("01/01/2024");
// Test timeOnly
const dTime: DateVal = { kind: "date", ms: 1704110400000, hasTime: true, timeZone: "UTC", timeOnly: true };
expect(formatValue(dTime, defaultSettings)).toBe("12:00");
});
it("formats base conversions", () => {
expect(formatValue(qty(255, null, "hex"), defaultSettings)).toBe("0xFF");
expect(formatValue(qty(-255, null, "hex"), defaultSettings)).toBe("-0xFF");
expect(formatValue(qty(5, null, "binary"), defaultSettings)).toBe("0b101");
expect(formatValue(qty(8, null, "octal"), defaultSettings)).toBe("0o10");
});
it("formats representations", () => {
expect(formatValue(qty(123456, null, "scientific"), defaultSettings)).toBe("1.23e5");
expect(formatValue(qty(1234.56, null, "plain"), defaultSettings)).toBe("1234.56");
expect(formatValue(qty(0.75, null, "fraction"), defaultSettings)).toBe("3/4");
expect(formatValue(qty(14, null, "roman"), defaultSettings)).toBe("XIV");
});
it("formats Roman numerals fallbacks", () => {
// Beyond 3999 falls back to formatNumber
expect(formatValue(qty(4000, null, "roman"), defaultSettings)).toBe("4,000");
});
it("formats units", () => {
const cm = { id: "cm", dimension: "length" as const, ratio: new Decimal(0.01), format: "cm" };
expect(formatValue(qty(10, cm), defaultSettings)).toBe("10 cm");
const degC = { id: "degC", dimension: "temperature" as const, ratio: new Decimal(1), format: "°C" };
expect(formatValue(qty(25, degC), defaultSettings)).toBe("25°C");
const usd = { id: "USD", dimension: "currency" as const, ratio: new Decimal(1), format: "${}" };
expect(formatValue(qty(100, usd), defaultSettings)).toBe("$100");
});
});

View file

@ -1,31 +1,6 @@
// Turns values into display strings: unit/currency formats plus // Turns values into display strings: unit/currency formats plus
// precision and grouping settings. // precision and grouping settings.
import { Decimal, DateVal, EngineSettings, Value } from "./types"; import { Decimal, EngineSettings, Value } from "./types";
// Intl.DateTimeFormat construction is relatively expensive and every line with
// a date rebuilds one on each render — cache by locale+options instead.
const dtfCache = new Map<string, Intl.DateTimeFormat>();
function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = (locale ?? "") + JSON.stringify(opts);
let f = dtfCache.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, opts);
dtfCache.set(key, f);
}
return f;
}
/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */
function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
const parts = dtf("en-GB", {
timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
}).formatToParts(v.ms);
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? "";
const [y, mo, d] = [get("year"), get("month"), get("day")];
const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`;
return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date;
}
function group(intPart: string, sep: string): string { function group(intPart: string, sep: string): string {
if (!sep) return intPart; if (!sep) return intPart;
@ -51,8 +26,6 @@ export function formatNumber(v: Decimal, s: EngineSettings): string {
} }
export function formatValue(v: Value, s: EngineSettings): string { export function formatValue(v: Value, s: EngineSettings): string {
if (v.kind === "chart") return "";
if (v.kind === "percent") { if (v.kind === "percent") {
return formatNumber(v.value, s) + "%"; return formatNumber(v.value, s) + "%";
} }
@ -60,23 +33,19 @@ export function formatValue(v: Value, s: EngineSettings): string {
if (v.kind === "date") { if (v.kind === "date") {
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone }; const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
if (v.timeOnly) { if (v.timeOnly) {
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms); return new Intl.DateTimeFormat(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
} }
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
if (v.hasTime) { if (v.hasTime) {
return dtf(undefined, { return new Intl.DateTimeFormat(undefined, {
...opts, hour: "2-digit", minute: "2-digit", hour12: false, ...opts, hour: "2-digit", minute: "2-digit", hour12: false,
day: "numeric", month: "short", year: "numeric", day: "numeric", month: "short", year: "numeric",
}).format(v.ms); }).format(v.ms);
} }
return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms); return new Intl.DateTimeFormat(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
} }
// quantity // quantity
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") { if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
if (v.value.abs().gte(new Decimal("1e10000"))) {
return formatNumber(v.value, s);
}
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP); const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
const neg = int.isNegative(); const neg = int.isNegative();
const abs = int.abs(); const abs = int.abs();
@ -112,9 +81,6 @@ function withUnit(num: string, f: string): string {
} }
/** Best rational approximation via continued fractions: 0.75 -> "3/4". */ /** Best rational approximation via continued fractions: 0.75 -> "3/4". */
// Native float, not Decimal: the continued-fraction algorithm is tuned to
// float epsilon by design, and this is display formatting, not arithmetic
// carried forward into the result.
function formatFraction(v: Decimal): string { function formatFraction(v: Decimal): string {
const neg = v.isNegative(); const neg = v.isNegative();
const x = v.abs().toNumber(); const x = v.abs().toNumber();

View file

@ -1,31 +1,17 @@
import { logger } from "../logger";
// Public facade: evaluates whole documents line by line and exposes the // Public facade: evaluates whole documents line by line and exposes the
// extension API (numi.addUnit / addFunction / setVariable). // extension API (numi.addUnit / addFunction / setVariable).
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types"; import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, defaultSettings, qty } from "./types";
import { Registry, buildRegistry, Completion } from "./registry"; import { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer"; import { tokenize, Token } from "./tokenizer";
import { parseLine, ParsedLine } from "./parser"; import { parseLine } from "./parser";
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator"; import { evaluate, EvalCtx, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter"; import { formatValue } from "./formatter";
import { detectDateOrder } from "./datetime";
import snapshot from "./rates-snapshot.json"; import snapshot from "./rates-snapshot.json";
import { CRYPTO } from "./extraunits"; import { CRYPTO } from "./extraunits";
const cryptoSnapshot: Record<string, number> = {}; const cryptoSnapshot: Record<string, number> = {};
for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd; for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd;
// "//" only starts a comment at line start or after whitespace, so URLs like
// "see https://example.com" aren't cut off.
const COMMENT_RE = /(^|\s)\/\//;
function findCommentStart(line: string): number {
const m = COMMENT_RE.exec(line);
return m ? m.index + m[1].length : -1;
}
function resolveDateOrder(fmt: EngineSettings["dateFormat"]): DateOrder {
return fmt === "dmy" || fmt === "mdy" ? fmt : detectDateOrder();
}
export interface LineResult { export interface LineResult {
/** formatted result, null when the line has none (text, comment, header, error) */ /** formatted result, null when the line has none (text, comment, header, error) */
text: string | null; text: string | null;
@ -35,10 +21,6 @@ export interface LineResult {
tokens: Token[]; tokens: Token[];
/** offset of "//" comment start within the line, if any */ /** offset of "//" comment start within the line, if any */
commentStart: number | null; commentStart: number | null;
/** name this line assigns, if any (`rent = $500` → "rent") */
assign?: string;
/** set when the line references an unresolved cross-sheet value */
error?: string;
} }
export interface ExtensionUnitSpec { export interface ExtensionUnitSpec {
@ -57,42 +39,29 @@ export interface ExtensionValue {
export class SumEngine { export class SumEngine {
reg: Registry; reg: Registry;
settings: EngineSettings; settings: EngineSettings;
/** variables defined by extensions, persist across evaluations */
private globals = new Map<string, Value>(); private globals = new Map<string, Value>();
private historicalRates = new Map<string, Map<string, number>>();
/** resolved day/month order for ambiguous date literals like "01/02/2024" */
private dateOrder: DateOrder;
constructor(settings: Partial<EngineSettings> = {}) { constructor(settings: Partial<EngineSettings> = {}) {
this.settings = { ...defaultSettings, ...settings }; this.settings = { ...defaultSettings, ...settings };
this.dateOrder = resolveDateOrder(this.settings.dateFormat);
this.reg = buildRegistry(); this.reg = buildRegistry();
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot }); this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
} }
setRates(rates: Record<string, number | string>): void { 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 }); this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates });
} }
setHistoricalRates(date: string, rates: Record<string, number>): void {
const m = new Map<string, number>();
for (const [k, v] of Object.entries(rates)) m.set(k.toUpperCase(), v);
this.historicalRates.set(date, m);
}
hasHistoricalRates(date: string): boolean {
return this.historicalRates.has(date);
}
updateSettings(patch: Partial<EngineSettings>): void { updateSettings(patch: Partial<EngineSettings>): void {
this.settings = { ...this.settings, ...patch }; this.settings = { ...this.settings, ...patch };
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat);
} }
completions(): Completion[] { completions(): Completion[] {
return this.reg.completions; return this.reg.completions;
} }
evaluateDocument(text: string, resolveXRef?: XRefResolver): LineResult[] { evaluateDocument(text: string): LineResult[] {
const lines = text.split("\n"); const lines = text.split("\n");
const results: LineResult[] = []; const results: LineResult[] = [];
const vars = new Map<string, Value>(this.globals); const vars = new Map<string, Value>(this.globals);
@ -101,7 +70,7 @@ export class SumEngine {
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i]; const rawLine = lines[i];
const commentStart = findCommentStart(rawLine); const commentStart = rawLine.indexOf("//");
const line = commentStart >= 0 ? rawLine.slice(0, commentStart) : rawLine; const line = commentStart >= 0 ? rawLine.slice(0, commentStart) : rawLine;
const trimmed = line.trim(); const trimmed = line.trim();
@ -119,53 +88,37 @@ export class SumEngine {
continue; continue;
} }
// tokenize/parseLine/evaluate all run under one try/catch: pathological const tokens = tokenize(line, this.reg);
// input (e.g. stack-overflowing nested parens in the parser) must null const knownVars = new Set(vars.keys());
// out this one line, not throw past evaluateDocument and kill the rest. const parsed = parseLine(tokens, knownVars, line);
let tokens: Token[] = [];
let parsed: ParsedLine = { expr: null };
let value: Value | null = null; let value: Value | null = null;
let lineError: string | undefined; if (parsed.expr) {
try { const ctx: EvalCtx = {
tokens = tokenize(line, this.reg, this.dateOrder); reg: this.reg,
const knownVars = new Set(vars.keys()); vars,
parsed = parseLine(tokens, knownVars, line); line: { lineValues, lineKinds, index: i },
if (parsed.expr) { };
const ctx: EvalCtx = { try {
reg: this.reg,
vars,
line: { lineValues, lineKinds, index: i, lineText: rawLine },
historicalRates: this.historicalRates,
resolveXRef,
};
value = evaluate(parsed.expr, ctx); value = evaluate(parsed.expr, ctx);
// a line without a result beats a dead sheet — swallow everything,
// including extension bugs and BigInt conversion errors
} catch (e) {
if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
value = null;
} }
} catch (e) { if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (e instanceof XRefError) { if (value && parsed.assign) vars.set(parsed.assign, value);
lineError = e.message;
} else if (e instanceof EvalError) {
// Expected evaluation failure; remain silent
} else {
// Capture unexpected execution anomalies like BigInt or Range errors
lineError = e instanceof Error ? e.message : String(e);
logger.warn("evaluate failed:", e);
}
// a line without a result beats a dead sheet.
value = null;
} }
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
lineValues.push(value); lineValues.push(value);
lineKinds.push("normal"); lineKinds.push("normal");
results.push({ results.push({
text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null, text: value ? formatValue(value, this.settings) : null,
value, value,
kind: "normal", kind: "normal",
tokens, tokens,
commentStart: commentStart >= 0 ? commentStart : null, commentStart: commentStart >= 0 ? commentStart : null,
assign: parsed.assign,
error: lineError,
}); });
} }
return results; return results;
@ -178,11 +131,11 @@ export class SumEngine {
} }
/** /**
* Grand total of the sheet: sums every line result compatible with the * Grand total of the sheet for the status bar: sums every line result
* first united result's dimension (converted to its unit). Returns null * compatible with the first united result's dimension (converted to its
* when fewer than two lines contribute. * unit). Returns null when fewer than two lines contribute.
*/ */
totalValueOf(results: LineResult[]): Quantity | null { totalOf(results: LineResult[]): string | null {
const qs = results const qs = results
.map((r) => r.value) .map((r) => r.value)
.filter((v): v is Quantity => v !== null && v.kind === "quantity"); .filter((v): v is Quantity => v !== null && v.kind === "quantity");
@ -201,13 +154,7 @@ export class SumEngine {
} }
if (count < 2) return null; if (count < 2) return null;
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc; const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
return qty(value, anchor.unit ?? null); return formatValue(qty(value, anchor.unit ?? null), this.settings);
}
/** Formatted string for the status bar; see totalValueOf for the raw value. */
totalOf(results: LineResult[]): string | null {
const v = this.totalValueOf(results);
return v ? formatValue(v, this.settings) : null;
} }
// ---------- extension API (numi.*) // ---------- extension API (numi.*)
@ -219,7 +166,6 @@ export class SumEngine {
addUnit(spec: ExtensionUnitSpec): void { addUnit(spec: ExtensionUnitSpec): void {
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId); const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`); if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
if (this.reg.unitsById.has(spec.id)) throw new Error(`numi.addUnit: id "${spec.id}" is already registered`);
const unit: Unit = { const unit: Unit = {
id: spec.id, id: spec.id,
dimension: base.dimension, dimension: base.dimension,
@ -265,12 +211,10 @@ export class SumEngine {
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) }; return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
} }
if (v.kind === "percent") return { double: v.value.div(100).toNumber() }; if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
if (v.kind === "date") return { double: v.ms }; return { double: v.ms };
return { double: 0 }; // chart — not representable as ExtensionValue
} }
} }
export { formatValue } from "./formatter"; export { formatValue } from "./formatter";
export type { Value, EngineSettings } from "./types"; export type { Value, EngineSettings } from "./types";
export type { Token } from "./tokenizer"; export type { Token } from "./tokenizer";
export type { XRefResolution, XRefResolver } from "./evaluator";

View file

@ -1,113 +0,0 @@
import { describe, it, expect } from "vitest";
import { lexLine } from "./lexer";
import { Decimal } from "./types";
describe("lexer", () => {
describe("lexLine", () => {
it("should lex words and symbols, ignoring spaces", () => {
const result = lexLine("hello + world");
expect(result).toEqual([
{ type: "word", raw: "hello", start: 0, end: 5 },
{ type: "sym", raw: "+", start: 6, end: 7 },
{ type: "word", raw: "world", start: 8, end: 13 },
]);
});
it("should lex ISO dates", () => {
const result = lexLine("2024-12-31");
expect(result).toEqual([
{ type: "date", raw: "2024-12-31", start: 0, end: 10, dateMs: new Date(2024, 11, 31).getTime() },
]);
});
it("should lex dmy dates", () => {
const result = lexLine("31.12.2024");
expect(result).toEqual([
{ type: "date", raw: "31.12.2024", start: 0, end: 10, dateMs: new Date(2024, 11, 31).getTime() },
]);
});
it("should lex ambiguous dates as dmy by default", () => {
const result = lexLine("01/02/2024");
expect(result).toEqual([
{ type: "date", raw: "01/02/2024", start: 0, end: 10, dateMs: new Date(2024, 1, 1).getTime() },
]);
});
it("should lex ambiguous dates as mdy if specified", () => {
const result = lexLine("01/02/2024", "mdy");
expect(result).toEqual([
{ type: "date", raw: "01/02/2024", start: 0, end: 10, dateMs: new Date(2024, 0, 2).getTime() },
]);
});
it("should fallback when invalid dates are encountered", () => {
// 2026-13-45 is invalid as a date, should lex as numbers and syms
const result = lexLine("2026-13-45");
expect(result[0].type).toBe("num");
expect(result[0].raw).toBe("2026");
expect(result[1].type).toBe("sym");
expect(result[1].raw).toBe("-");
});
it("should lex plain integers", () => {
const result = lexLine("42");
expect(result).toEqual([
{ type: "num", raw: "42", start: 0, end: 2, value: new Decimal("42"), repr: "decimal" },
]);
});
it("should lex decimal comma numbers", () => {
const result = lexLine("42,5");
expect(result).toEqual([
{ type: "num", raw: "42,5", start: 0, end: 4, value: new Decimal("42.5"), repr: "decimal" },
]);
});
it("should lex space-grouped numbers", () => {
const result = lexLine("1 000 000,5");
expect(result).toEqual([
{ type: "num", raw: "1 000 000,5", start: 0, end: 11, value: new Decimal("1000000.5"), repr: "decimal" },
]);
});
it("should lex comma-grouped numbers", () => {
const result = lexLine("1,000,000.5");
expect(result).toEqual([
{ type: "num", raw: "1,000,000.5", start: 0, end: 11, value: new Decimal("1000000.5"), repr: "decimal" },
]);
});
it("should lex numbers with leading dots", () => {
const result = lexLine(".5");
expect(result).toEqual([
{ type: "num", raw: ".5", start: 0, end: 2, value: new Decimal(".5"), repr: "decimal" },
]);
});
it("should lex base numbers (hex, bin, oct)", () => {
const hex = lexLine("0xff");
expect(hex[0]).toMatchObject({ type: "num", raw: "0xff", value: new Decimal(255), repr: "hex" });
const bin = lexLine("0b1010");
expect(bin[0]).toMatchObject({ type: "num", raw: "0b1010", value: new Decimal(10), repr: "binary" });
const oct = lexLine("0o77");
expect(oct[0]).toMatchObject({ type: "num", raw: "0o77", value: new Decimal(63), repr: "octal" });
});
it("should lex bare cross-references", () => {
const result = lexLine("@sheet.key");
expect(result).toEqual([
{ type: "xref", raw: "@sheet.key", start: 0, end: 10, sheet: "sheet", key: "key" },
]);
});
it("should lex bracketed cross-references", () => {
const result = lexLine("@[my sheet].key");
expect(result).toEqual([
{ type: "xref", raw: "@[my sheet].key", start: 0, end: 15, sheet: "my sheet", key: "key" },
]);
});
});
});

View file

@ -1,17 +1,14 @@
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the // Splits a line into raw lexemes (numbers, words, symbols). Shared by the
// tokenizer and the phrase registry so both segment text the same way. // tokenizer and the phrase registry so both segment text the same way.
import { DateOrder, Decimal, NumeralRepr } from "./types"; import { Decimal, NumeralRepr } from "./types";
export interface Lex { export interface Lex {
type: "num" | "word" | "sym" | "date" | "xref"; type: "num" | "word" | "sym";
raw: string; raw: string;
start: number; start: number;
end: number; end: number;
value?: Decimal; value?: Decimal;
repr?: NumeralRepr; repr?: NumeralRepr;
dateMs?: number;
sheet?: string;
key?: string;
} }
const WORD_RE = /^[\p{L}_]+/u; const WORD_RE = /^[\p{L}_]+/u;
@ -23,111 +20,8 @@ const NUM_SPACE_GROUPED_RE = /^\d{1,3}(?:[   ]\d{3})+(?:[.,]\d+)?(?!\d)/;
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/; const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/; const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/; const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
// 31.12.2024 / 12/31/2024 — same separator on both sides, 4-digit year
const SLASH_DOT_DATE_RE = /^(\d{1,2})([./])(\d{1,2})\2(\d{4})(?!\d)/;
/** ms for a real calendar date; null when the components roll over (2026-13-45) */ export function lexLine(text: string): Lex[] {
function validDateMs(y: number, mo: number, d: number): number | null {
const dt = new Date(y, mo - 1, d);
return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d
? dt.getTime()
: null;
}
function tryLexDate(rest: string, i: number, dateOrder: DateOrder): Lex | null {
const iso = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(rest);
if (iso) {
const ms = validDateMs(Number(iso[1]), Number(iso[2]), Number(iso[3]));
if (ms !== null) {
return { type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms };
}
}
const dm = SLASH_DOT_DATE_RE.exec(rest);
if (dm) {
const a = Number(dm[1]);
const b = Number(dm[3]);
// a>12 must be the day, b>12 must be the month; both ≤12 → configured order
const dayFirst = a > 12 ? true : b > 12 ? false : dateOrder === "dmy";
const y = Number(dm[4]);
const ms = dayFirst ? validDateMs(y, b, a) : validDateMs(y, a, b);
if (ms !== null) {
return { type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms };
}
}
return null;
}
function tryLexBaseNumber(rest: string, i: number): Lex | null {
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
if (hex) {
return { type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" };
}
const bin = /^0[bB][01]+/.exec(rest);
if (bin) {
return { type: "num", raw: bin[0], start: i, end: i + bin[0].length, value: new Decimal(parseInt(bin[0].slice(2), 2)), repr: "binary" };
}
const oct = /^0[oO][0-7]+/.exec(rest);
if (oct) {
return { type: "num", raw: oct[0], start: i, end: i + oct[0].length, value: new Decimal(parseInt(oct[0].slice(2), 8)), repr: "octal" };
}
return null;
}
function tryLexDecimalNumber(rest: string, i: number): Lex | null {
let raw: string | null = null;
let cleaned = "";
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
const cg = sp ? null : NUM_COMMA_GROUPED_RE.exec(rest);
const dc = sp || cg ? null : NUM_DECIMAL_COMMA_RE.exec(rest);
const pl = sp || cg || dc ? null : NUM_PLAIN_RE.exec(rest);
if (sp) {
raw = sp[0];
cleaned = raw.replace(/[   ]/g, "").replace(",", ".");
} else if (cg) {
raw = cg[0];
cleaned = raw.replace(/,/g, "");
} else if (dc) {
raw = dc[0];
cleaned = raw.replace(",", ".");
} else if (pl) {
raw = pl[0];
cleaned = raw;
}
if (raw) {
return { type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), repr: "decimal" };
}
return null;
}
function tryLexDotNumber(rest: string, i: number): Lex | null {
if (/^\.\d/.test(rest)) {
const m = /^\.\d+/.exec(rest)!;
return { type: "num", raw: m[0], start: i, end: i + m[0].length, value: new Decimal(m[0]), repr: "decimal" };
}
return null;
}
function tryLexXRef(rest: string, i: number): Lex | null {
const bracket = /^@\[([^\]]+)\]\.([\p{L}_][\p{L}\d_]*)/u.exec(rest);
if (bracket) {
return { type: "xref", raw: bracket[0], start: i, end: i + bracket[0].length, sheet: bracket[1].trim(), key: bracket[2] };
}
const bare = /^@([\p{L}_][\p{L}\d_]*)\.([\p{L}_][\p{L}\d_]*)/u.exec(rest);
if (bare) {
return { type: "xref", raw: bare[0], start: i, end: i + bare[0].length, sheet: bare[1], key: bare[2] };
}
return null;
}
function tryLexWord(rest: string, i: number): Lex | null {
const word = WORD_RE.exec(rest);
if (word) {
return { type: "word", raw: word[0], start: i, end: i + word[0].length };
}
return null;
}
export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
const out: Lex[] = []; const out: Lex[] = [];
let i = 0; let i = 0;
while (i < text.length) { while (i < text.length) {
@ -138,21 +32,63 @@ export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
} }
const rest = text.slice(i); const rest = text.slice(i);
let lex: Lex | null = null;
if (ch >= "0" && ch <= "9") { if (ch >= "0" && ch <= "9") {
lex = tryLexDate(rest, i, dateOrder) || tryLexBaseNumber(rest, i) || tryLexDecimalNumber(rest, i); const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
} else if (ch === ".") { if (hex) {
lex = tryLexDotNumber(rest, i); out.push({ type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" });
} else if (ch === "@") { i += hex[0].length;
lex = tryLexXRef(rest, i); 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;
}
} }
if (!lex) { if (ch >= "0" && ch <= "9") {
lex = tryLexWord(rest, i); let raw: string | null = null;
let cleaned = "";
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
const cg = sp ? null : NUM_COMMA_GROUPED_RE.exec(rest);
const dc = sp || cg ? null : NUM_DECIMAL_COMMA_RE.exec(rest);
const pl = sp || cg || dc ? null : NUM_PLAIN_RE.exec(rest);
if (sp) {
raw = sp[0];
cleaned = raw.replace(/[   ]/g, "").replace(",", ".");
} else if (cg) {
raw = cg[0];
cleaned = raw.replace(/,/g, "");
} else if (dc) {
raw = dc[0];
cleaned = raw.replace(",", ".");
} else if (pl) {
raw = pl[0];
cleaned = raw;
}
if (raw) {
out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), 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;
} }
if (lex) { const word = WORD_RE.exec(rest);
out.push(lex); if (word) {
i += lex.raw.length; out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length });
i += word[0].length;
continue; continue;
} }

View file

@ -1,95 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseLine } from "./parser";
import { tokenize } from "./tokenizer";
import { buildRegistry } from "./registry";
import { Decimal } from "./types";
const reg = buildRegistry();
function parse(line: string, knownVars = new Set<string>()) {
const tokens = tokenize(line, reg);
return parseLine(tokens, knownVars, line);
}
describe("parseLine", () => {
it("parses basic expressions", () => {
const res = parse("2 + 2");
expect(res.assign).toBeUndefined();
expect(res.expr).toMatchObject({
k: "bin",
op: "plus",
l: { k: "num", v: new Decimal(2) },
r: { k: "num", v: new Decimal(2) }
});
});
it("parses variable assignment with '='", () => {
const res = parse("x = 5 + 3");
expect(res.assign).toBe("x");
expect(res.expr).toMatchObject({
k: "bin",
op: "plus",
l: { k: "num", v: new Decimal(5) },
r: { k: "num", v: new Decimal(3) }
});
});
it("parses variable assignment with 'is'", () => {
const res = parse("myVar is 10");
expect(res.assign).toBe("myVar");
expect(res.expr).toMatchObject({
k: "num",
v: new Decimal(10)
});
});
it("rejects invalid variable names for assignment", () => {
const res = parse("123 = 5");
expect(res.assign).toBeUndefined();
});
it("handles known variables shadowing built-ins", () => {
// "m" is normally a unit (meters), but if it's a known variable, it's treated as a word
const res = parse("m = 5", new Set(["m"]));
expect(res.assign).toBe("m");
expect(res.expr).toMatchObject({
k: "num",
v: new Decimal(5)
});
});
it("filters noise words", () => {
// "what is 2 + 2" -> "what" and "is" are noise, leaving "2 + 2"
const res = parse("what is 2 + 2");
expect(res.expr).toMatchObject({
k: "bin",
op: "plus"
});
});
it("keeps words after a conversion operator (e.g., timezones)", () => {
// "now in Europe/Berlin"
// "Europe/Berlin" should be kept as a timezone, not dropped as noise.
const res = parse("now in Europe/Berlin");
expect(res.expr).toMatchObject({
k: "conv",
target: {
type: "tz"
}
});
});
it("parses goal seek expressions", () => {
const res = parse("? * 1.2 = 1000");
expect(res.expr).toMatchObject({
k: "goalseek",
lhs: { k: "bin", op: "mul", l: { k: "unknown" }, r: { k: "num", v: new Decimal(1.2) } },
rhs: { k: "num", v: new Decimal(1000) }
});
});
it("returns null expression for empty or noise-only lines", () => {
const res = parse("just some noise");
expect(res.expr).toBeNull();
});
});

View file

@ -7,7 +7,7 @@ import { PctOp, DateWord, BitOp } from "./registry";
export type ConvTarget = export type ConvTarget =
| { type: "unit"; unit: Unit } | { type: "unit"; unit: Unit }
| { type: "currency"; code: string; onDate?: string } | { type: "currency"; code: string }
| { type: "repr"; repr: NumeralRepr } | { type: "repr"; repr: NumeralRepr }
| { type: "tz"; words: string[] } | { type: "tz"; words: string[] }
| { type: "scale"; mult: Decimal; label: string } | { type: "scale"; mult: Decimal; label: string }
@ -19,9 +19,7 @@ export type Node =
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" } | { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
| { k: "var"; name: string } | { k: "var"; name: string }
| { k: "date"; word: DateWord } | { k: "date"; word: DateWord }
| { k: "datelit"; ms: number } | { k: "agg"; name: "sum" | "avg" | "prev" }
| { k: "xref"; sheet: string; key: string }
| { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" }
| { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node } | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
| { k: "bit"; op: BitOp; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node }
| { k: "pctop"; op: PctOp; l: Node; r: Node } | { k: "pctop"; op: PctOp; l: Node; r: Node }
@ -33,9 +31,7 @@ export type Node =
| { k: "scale"; x: Node; mult: Decimal; label: string } | { k: "scale"; x: Node; mult: Decimal; label: string }
| { k: "conv"; x: Node; target: ConvTarget } | { k: "conv"; x: Node; target: ConvTarget }
| { k: "call"; name: string; args: Node[] } | { k: "call"; name: string; args: Node[] }
| { k: "seq"; items: Node[] } | { k: "seq"; items: Node[] };
| { k: "unknown" }
| { k: "goalseek"; lhs: Node; rhs: Node };
export interface ParsedLine { export interface ParsedLine {
assign?: string; assign?: string;
@ -62,18 +58,6 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
} }
} }
const filtered = filterNoiseTokens(toks, knownVars);
const goalSeek = tryParseGoalSeek(filtered, knownVars);
if (goalSeek) {
return { expr: goalSeek };
}
const p = new Parser(filtered, knownVars);
return { assign, expr: p.parseSeq() };
}
function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
// Noise filtering: drop junk and unknown words, but keep words that follow // Noise filtering: drop junk and unknown words, but keep words that follow
// a conversion operator (potential timezone names). // a conversion operator (potential timezone names).
const filtered: Token[] = []; const filtered: Token[] = [];
@ -84,43 +68,14 @@ function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
if (knownVars.has(tk.raw) || afterConv) filtered.push(tk); if (knownVars.has(tk.raw) || afterConv) filtered.push(tk);
continue; continue;
} }
// a bare "/" inside a timezone name ("Europe/Berlin") must not break the
// word-keeping chain, otherwise the second half gets filtered as noise
if (afterConv && tk.t === "op" && tk.op === "div") {
filtered.push(tk);
continue;
}
afterConv = tk.t === "conv"; afterConv = tk.t === "conv";
filtered.push(tk); filtered.push(tk);
} }
return filtered;
const p = new Parser(filtered, knownVars);
return { assign, expr: p.parseSeq() };
} }
function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | null {
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
const hasUnknown = filtered.some((tk) => tk.t === "unknown");
if (hasUnknown) {
const assignIdx = filtered.findIndex((tk) => tk.t === "assign");
if (assignIdx >= 0) {
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq();
if (lhs && rhs) return { k: "goalseek", lhs, rhs };
}
}
return null;
}
const PREC = {
CONV: 10,
BIT: 20,
ADD: 30,
MUL: 40,
UNARY: 50,
POW: 55,
POSTFIX: 60,
};
class Parser { class Parser {
private i = 0; private i = 0;
constructor(private toks: Token[], private vars: Set<string>) {} constructor(private toks: Token[], private vars: Set<string>) {}
@ -146,7 +101,7 @@ class Parser {
this.i++; this.i++;
const target = this.parseTarget(); const target = this.parseTarget();
if (target) { if (target) {
items = [{ k: "conv", x: mk(), target: this.tryHistoricalDate(target) }]; items = [{ k: "conv", x: mk(), target }];
continue; continue;
} }
this.i = save + 1; // drop the conversion word, it was noise this.i = save + 1; // drop the conversion word, it was noise
@ -154,7 +109,7 @@ class Parser {
} }
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) && items.length > 0) { if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) && items.length > 0) {
this.i++; this.i++;
const r = this.parseExpr(PREC.ADD); const r = this.parseAdd();
if (r) { if (r) {
items = [{ k: "pctop", op: tk.op, l: mk(), r }]; items = [{ k: "pctop", op: tk.op, l: mk(), r }];
continue; continue;
@ -165,7 +120,7 @@ class Parser {
if (tk.t === "op" && items.length > 0) { if (tk.t === "op" && items.length > 0) {
const save = this.i; const save = this.i;
this.i++; this.i++;
const r = tk.op === "plus" || tk.op === "minus" ? this.parseExpr(PREC.MUL) : this.parseExpr(PREC.UNARY); const r = tk.op === "plus" || tk.op === "minus" ? this.parseMul() : this.parseUnary();
if (r) { if (r) {
items = [{ k: "bin", op: tk.op, l: mk(), r }]; items = [{ k: "bin", op: tk.op, l: mk(), r }];
continue; continue;
@ -176,7 +131,7 @@ class Parser {
if (tk.t === "bitop" && items.length > 0) { if (tk.t === "bitop" && items.length > 0) {
const save = this.i; const save = this.i;
this.i++; this.i++;
const r = this.parseExpr(PREC.ADD); const r = this.parseAdd();
if (r) { if (r) {
items = [{ k: "bit", op: tk.op, l: mk(), r }]; items = [{ k: "bit", op: tk.op, l: mk(), r }];
continue; continue;
@ -185,7 +140,7 @@ class Parser {
continue; continue;
} }
const before = this.i; const before = this.i;
const node = this.parseExpr(PREC.BIT); const node = this.parseBit();
if (node) items.push(node); if (node) items.push(node);
if (this.i === before) this.i++; // skip unparsable token if (this.i === before) this.i++; // skip unparsable token
} }
@ -193,177 +148,50 @@ class Parser {
return mk(); return mk();
} }
/** /** bitwise level (one flat precedence, left-assoc): & | xor << >> */
* Pratt parser implementation for expressions. private parseBit(): Node | null {
*/ let l = this.parseAdd();
private parseExpr(minPrec: number = 0): Node | null { if (!l) return null;
let tk = this.peek(); for (;;) {
if (!tk) return null; const tk = this.peek();
if (tk?.t !== "bitop") break;
let lhs: Node | null = null; const save = this.i;
if (tk.t === "op" && tk.op === "minus") {
this.i++; this.i++;
const x = this.parseExpr(PREC.UNARY); const r = this.parseAdd();
if (!x) return null; if (!r) {
lhs = { k: "neg", x }; this.i = save;
} else if (tk.t === "op" && tk.op === "plus") { break;
this.i++;
lhs = this.parseExpr(PREC.UNARY);
if (!lhs) return null;
} else {
lhs = this.parsePrimary();
}
if (!lhs) return null;
loop: for (;;) {
tk = this.peek();
if (!tk) break;
switch (tk.t) {
case "percent": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "pct", x: lhs };
continue loop;
}
case "bang": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "fact", x: lhs };
continue loop;
}
case "unit": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "unit", x: lhs, unit: tk.unit };
continue loop;
}
case "currency": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "curr", x: lhs, code: tk.code };
continue loop;
}
case "scale": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
continue loop;
}
case "op": {
switch (tk.op) {
case "pow": {
if (PREC.POW < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.POW);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: "pow", l: lhs, r };
continue loop;
}
case "mul":
case "div":
case "mod": {
if (PREC.MUL < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: tk.op, l: lhs, r };
continue loop;
}
case "plus":
case "minus": {
if (PREC.ADD < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: tk.op, l: lhs, r };
continue loop;
}
}
break loop;
}
case "pctop": {
if (tk.op === "of" || tk.op === "off" || tk.op === "on") {
if (!(lhs.k === "pct" || lhs.k === "var" || lhs.k === "pctop")) {
this.i++;
continue loop;
}
if (PREC.MUL < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "pctop", op: tk.op, l: lhs, r };
continue loop;
}
if (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) {
if (PREC.CONV < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD);
if (!r) { this.i = save; break loop; }
lhs = { k: "pctop", op: tk.op, l: lhs, r };
continue loop;
}
break loop;
}
case "lparen":
case "const":
case "func":
case "word": {
if (tk.t === "word" && !this.vars.has(tk.raw)) break loop;
if (PREC.MUL < minPrec) break loop;
const save = this.i;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: "mul", l: lhs, r };
continue loop;
}
case "bitop": {
if (PREC.BIT < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.BIT + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bit", op: tk.op, l: lhs, r };
continue loop;
}
case "conv": {
if (PREC.CONV < minPrec) break loop;
const save = this.i;
this.i++;
const target = this.parseTarget();
if (!target) { this.i = save; break loop; }
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
continue loop;
}
} }
l = { k: "bit", op: tk.op, l, r };
break;
} }
return lhs; return l;
} }
/** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */
private tryHistoricalDate(target: ConvTarget): ConvTarget { /** conversion level: `x in cm`, `x as a % of y`, `x of what is y` */
if (target.type !== "currency") return target; private parseConv(): Node | null {
const nx = this.peek(); let l = this.parseBit();
if (nx?.t !== "pctop" || nx.op !== "on") return target; if (!l) return null;
const save = this.i; for (;;) {
this.i++; const tk = this.peek();
const dk = this.peek(); if (tk?.t === "conv") {
if (dk?.t === "datelit") { const save = this.i;
this.i++; this.i++;
const d = new Date(dk.ms); const target = this.parseTarget();
const onDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; if (!target) {
return { ...target, onDate }; 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;
}
} }
this.i = save; return l;
return target;
} }
private parseTarget(): ConvTarget | null { private parseTarget(): ConvTarget | null {
@ -395,24 +223,130 @@ class Parser {
} }
if (tk.t === "word") { if (tk.t === "word") {
const words: string[] = []; const words: string[] = [];
for (;;) { while (this.peek()?.t === "word" && words.length < 4) {
const w = this.peek(); words.push((this.next() as Token & { raw: string }).raw);
if (w?.t === "word" && words.length < 4) {
words.push((this.next() as Token & { raw: string }).raw);
continue;
}
// "Europe/Berlin": a slash between timezone words is part of the name
if (w?.t === "op" && w.op === "div" && words.length > 0 && this.toks[this.i + 1]?.t === "word") {
this.next();
continue;
}
break;
} }
if (words.length > 0) return { type: "tz", words }; if (words.length > 0) return { type: "tz", words };
} }
return null; 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 { private parsePrimary(): Node | null {
const tk = this.peek(); const tk = this.peek();
if (!tk) return null; if (!tk) return null;
@ -426,12 +360,6 @@ class Parser {
case "date": case "date":
this.i++; this.i++;
return { k: "date", word: tk.word }; return { k: "date", word: tk.word };
case "datelit":
this.i++;
return { k: "datelit", ms: tk.ms };
case "xref":
this.i++;
return { k: "xref", sheet: tk.sheet, key: tk.key };
case "agg": case "agg":
this.i++; this.i++;
return { k: "agg", name: tk.name }; return { k: "agg", name: tk.name };
@ -448,7 +376,7 @@ class Parser {
this.i++; this.i++;
const args: Node[] = []; const args: Node[] = [];
for (;;) { for (;;) {
const arg = this.parseExpr(PREC.CONV); const arg = this.parseConv();
if (arg) args.push(arg); if (arg) args.push(arg);
const nx = this.peek(); const nx = this.peek();
if (nx?.t === "semicolon") { if (nx?.t === "semicolon") {
@ -469,12 +397,12 @@ class Parser {
// `sqrt 16` / `square root of 16` — the "of" is decorative // `sqrt 16` / `square root of 16` — the "of" is decorative
const nx = this.peek(); const nx = this.peek();
if (nx?.t === "pctop" && nx.op === "of") this.i++; if (nx?.t === "pctop" && nx.op === "of") this.i++;
const arg = this.parseExpr(PREC.UNARY); const arg = this.parseUnary();
return arg ? { k: "call", name, args: [arg] } : null; return arg ? { k: "call", name, args: [arg] } : null;
} }
case "lparen": { case "lparen": {
this.i++; this.i++;
const inner = this.parseExpr(PREC.CONV); const inner = this.parseConv();
if (this.peek()?.t === "rparen") this.i++; if (this.peek()?.t === "rparen") this.i++;
return inner; return inner;
} }
@ -487,9 +415,6 @@ class Parser {
} }
return null; return null;
} }
case "unknown":
this.i++;
return { k: "unknown" };
default: default:
return null; return null;
} }

View file

@ -1,162 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { buildRegistry, Registry } from "./registry";
import { lexLine } from "./lexer";
import { Decimal } from "./types";
describe("registry", () => {
let reg: Registry;
beforeEach(() => {
reg = buildRegistry();
});
describe("buildRegistry", () => {
it("initializes a valid Registry instance with core vocabulary", () => {
// Check core words
const plusPayload = reg.match(lexLine("plus"), ["plus"], 0);
expect(plusPayload).not.toBeNull();
expect(plusPayload?.payload.t).toBe("op");
if (plusPayload?.payload.t === "op") expect(plusPayload.payload.op).toBe("plus");
const piPayload = reg.match(lexLine("pi"), ["pi"], 0);
expect(piPayload).not.toBeNull();
expect(piPayload?.payload.t).toBe("const");
if (piPayload?.payload.t === "const") expect(piPayload.payload.name).toBe("pi");
});
it("registers built-in functions", () => {
expect(reg.isFunc("sin")).toBe(true);
expect(reg.isFunc("sqrt")).toBe(true);
expect(reg.isFunc("random")).toBe(true);
expect(reg.isFunc("unknownFunc")).toBe(false);
const sinPayload = reg.match(lexLine("sin"), ["sin"], 0);
expect(sinPayload).not.toBeNull();
expect(sinPayload?.payload.t).toBe("func");
});
it("registers common units", () => {
// "meter"
const meterPayload = reg.match(lexLine("meter"), ["meter"], 0);
expect(meterPayload).not.toBeNull();
expect(meterPayload?.payload.t).toBe("unit");
// "kg" (strict/lenient)
const kgPayload = reg.match(lexLine("kg"), ["kg"], 0);
expect(kgPayload).not.toBeNull();
expect(kgPayload?.payload.t).toBe("unit");
// "USD" (strict/lenient currency)
const usdPayload = reg.match(lexLine("USD"), ["usd"], 0);
expect(usdPayload).not.toBeNull();
expect(usdPayload?.payload.t).toBe("currency");
});
});
describe("match", () => {
it("matches single-word phrases", () => {
const match = reg.match(lexLine("usd"), ["usd"], 0);
expect(match).not.toBeNull();
expect(match?.length).toBe(1);
});
it("matches multi-word phrases", () => {
const match = reg.match(lexLine("square meter"), ["square", "meter"], 0);
expect(match).not.toBeNull();
expect(match?.length).toBe(2);
expect(match?.payload.t).toBe("unit");
});
it("respects case-sensitivity", () => {
// "m" (milli) vs "M" (mega)
const mMatch = reg.match(lexLine("mm"), ["mm"], 0);
const mPayload = mMatch?.payload;
expect(mPayload?.t).toBe("unit");
const MMatch = reg.match(lexLine("Mm"), ["mm"], 0);
const MPayload = MMatch?.payload;
expect(MPayload?.t).toBe("unit");
if (mPayload?.t === "unit" && MPayload?.t === "unit") {
expect(mPayload.unit.id).not.toBe(MPayload.unit.id);
expect(mPayload.unit.id).toBe("milli:meter");
expect(MPayload.unit.id).toBe("mega:meter");
}
});
it("respects mustTouch flag", () => {
// "log2" vs "log 2"
const lex2 = lexLine("log2");
const log2Match = reg.match(lex2, lex2.map(l => l.raw), 0);
expect(log2Match).not.toBeNull();
expect(log2Match?.payload.t).toBe("func");
if (log2Match?.payload.t === "func") expect(log2Match.payload.name).toBe("log2");
const lexSpace2 = lexLine("log 2");
const logSpace2Match = reg.match(lexSpace2, lexSpace2.map(l => l.raw), 0);
// "log 2" should match "log", length 1, not "log2"
expect(logSpace2Match).not.toBeNull();
expect(logSpace2Match?.length).toBe(1);
if (logSpace2Match?.payload.t === "func") expect(logSpace2Match.payload.name).toBe("log");
});
it("returns null for no match", () => {
const match = reg.match(lexLine("unknownword"), ["unknownword"], 0);
expect(match).toBeNull();
});
});
describe("currency and rates", () => {
it("sets and updates conversion rates", () => {
reg.setRates({ "EUR": 0.9, "GBP": "0.8" });
expect(reg.rates.get("EUR")?.toNumber()).toBe(0.9);
expect(reg.rates.get("GBP")?.toNumber()).toBe(0.8);
// USD is implicitly set to 1
expect(reg.rates.get("USD")?.toNumber()).toBe(1);
});
it("ignores negative rates", () => {
reg.setRates({ "EUR": 0.9, "INVALID": -1 });
expect(reg.rates.get("EUR")?.toNumber()).toBe(0.9);
expect(reg.rates.has("INVALID")).toBe(false);
});
it("creates currency unit from rate", () => {
const unit = reg.makeCurrencyUnitFromRate("EUR", new Decimal(0.9));
expect(unit.id).toBe("EUR");
expect(unit.dimension).toBe("currency");
// 1 / 0.9 = 1.111...
expect(unit.ratio.toNumber()).toBeCloseTo(1.1111, 4);
});
it("makes currency unit if rate exists", () => {
reg.setRates({ "EUR": 0.9 });
const unit = reg.makeCurrencyUnit("EUR");
expect(unit).not.toBeNull();
expect(unit?.id).toBe("EUR");
const noUnit = reg.makeCurrencyUnit("JPY");
expect(noUnit).toBeNull();
});
});
describe("completions", () => {
it("populates completions with expected categories", () => {
expect(reg.completions.length).toBeGreaterThan(0);
const types = new Set(reg.completions.map(c => c.type));
expect(types.has("unit")).toBe(true);
expect(types.has("currency")).toBe(true);
expect(types.has("function")).toBe(true);
expect(types.has("keyword")).toBe(true);
});
it("contains specific known completions", () => {
const labels = reg.completions.map(c => c.label);
expect(labels).toContain("USD");
expect(labels).toContain("meter");
expect(labels).toContain("sin");
expect(labels).toContain("sum");
});
});
});

View file

@ -8,7 +8,6 @@ import * as vocab from "./vocab";
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata"; import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
import { EXTRA_UNITS, CRYPTO } from "./extraunits"; import { EXTRA_UNITS, CRYPTO } from "./extraunits";
/** Operators for percentage calculations. */
export type PctOp = export type PctOp =
| "of" | "off" | "on" | "of" | "off" | "on"
| "as_pct_of" | "as_pct_off" | "as_pct_on" | "as_pct_of" | "as_pct_off" | "as_pct_on"
@ -28,7 +27,7 @@ export type Payload =
| { t: "pctop"; op: PctOp } | { t: "pctop"; op: PctOp }
| { t: "percent" } | { t: "percent" }
| { t: "func"; name: string } | { t: "func"; name: string }
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { t: "agg"; name: "sum" | "avg" | "prev" }
| { t: "scale"; mult: Decimal } | { t: "scale"; mult: Decimal }
| { t: "repr"; repr: NumeralRepr } | { t: "repr"; repr: NumeralRepr }
| { t: "special"; name: "unix" | "todate" } | { t: "special"; name: "unix" | "todate" }
@ -59,7 +58,7 @@ const BUILTIN_FUNCS = [
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan", "sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor", "ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
"fact", "factorial", "random", "until", "since", "fact", "factorial",
]; ];
export class Registry { export class Registry {
@ -135,14 +134,10 @@ export class Registry {
makeCurrencyUnit(code: string): Unit | null { makeCurrencyUnit(code: string): Unit | null {
const rate = this.rates.get(code); const rate = this.rates.get(code);
if (!rate) return null; if (!rate) return null;
return this.makeCurrencyUnitFromRate(code, rate);
}
makeCurrencyUnitFromRate(code: string, ratePerUsd: Decimal): Unit {
return { return {
id: code, id: code,
dimension: "currency", dimension: "currency",
ratio: new Decimal(1).div(ratePerUsd), // USD per 1 unit ratio: new Decimal(1).div(rate), // USD per 1 unit
format: this.currencyFormat.get(code) ?? `{} ${code}`, format: this.currencyFormat.get(code) ?? `{} ${code}`,
}; };
} }
@ -157,50 +152,30 @@ function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { ca
for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts); for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts);
} }
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
export function buildRegistry(): Registry { export function buildRegistry(): Registry {
const reg = new Registry(); const reg = new Registry();
registerCoreVocab(reg);
const lengthPhrases = registerUnits(reg);
registerAreaVolume(reg, lengthPhrases);
registerCurrencies(reg);
registerExtraUnits(reg);
registerCrypto(reg);
registerCompletions(reg);
return reg;
}
// operators, percent, dates, numerals, scales, aggregates, built-in functions // operators, percent, dates, numerals, scales, aggregates
function registerCoreVocab(reg: Registry): void {
const ops: Array<[string, Payload]> = [ const ops: Array<[string, Payload]> = [
["plus", { t: "op", op: "plus" }], ["plus", { t: "op", op: "plus" }],
["minus", { t: "op", op: "minus" }], ["minus", { t: "op", op: "minus" }],
["multiply", { t: "op", op: "mul" }], ["multiply", { t: "op", op: "mul" }],
["divide", { t: "op", op: "div" }], ["divide", { t: "op", op: "div" }],
["conversion", { t: "conv" }],
["assignment", { t: "assign" }],
["sum", { t: "agg", name: "sum" }],
["avg", { t: "agg", name: "avg" }],
["prev", { t: "agg", name: "prev" }],
["count", { t: "agg", name: "count" }],
["min", { t: "agg", name: "min" }],
["max", { t: "agg", name: "max" }],
["product", { t: "agg", name: "product" }],
["chart", { t: "agg", name: "chart" }],
["sqrt", { t: "func", name: "sqrt" }],
["cbrt", { t: "func", name: "cbrt" }],
["abs", { t: "func", name: "abs" }],
["ln", { t: "func", name: "ln" }],
["log", { t: "func", name: "log" }],
["round", { t: "func", name: "round" }],
["random", { t: "func", name: "random" }],
["until", { t: "func", name: "until" }],
["since", { t: "func", name: "since" }],
]; ];
for (const [key, payload] of ops) { 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", `${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 });
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
@ -268,11 +243,9 @@ function registerCoreVocab(reg: Registry): void {
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false }); reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false }); for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
}
// UNIT_DATA + SI/IEC prefixes; returns the length-dimension phrases the // units, SI/IEC prefixes, area/volume templates
// area/volume templates need to build "square meter" / "cubic foot" etc. interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
function registerUnits(reg: Registry): LengthPhrase[] {
const lengthPhrases: LengthPhrase[] = []; const lengthPhrases: LengthPhrase[] = [];
/** lowercased composed symbol -> unit; null = ambiguous (mm vs Mm) */ /** lowercased composed symbol -> unit; null = ambiguous (mm vs Mm) */
const lenientSym = new Map<string, Unit | null>(); const lenientSym = new Map<string, Unit | null>();
@ -350,11 +323,7 @@ function registerUnits(reg: Registry): LengthPhrase[] {
if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false }); if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });
} }
return lengthPhrases; // Area/volume from templates: "square %@" / "cubic %@" applied to every length phrase
}
// "square %@" / "cubic %@" applied to every length phrase from registerUnits
function registerAreaVolume(reg: Registry, lengthPhrases: LengthPhrase[]): void {
const applyTemplates = ( const applyTemplates = (
templates: string[], templates: string[],
fmtTemplate: string, fmtTemplate: string,
@ -382,11 +351,10 @@ function registerAreaVolume(reg: Registry, lengthPhrases: LengthPhrase[]): void
}; };
applyTemplates(vocab.variants("Area", "area.templates"), vocab.entryEn("Area", "area.format") ?? "%@²", "area", 2, "sq"); 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"); applyTemplates(vocab.variants("Volume", "volume.templates"), vocab.entryEn("Volume", "volume.format") ?? "%@³", "volume", 3, "cb");
}
// Major currencies register first so they win generic phrases // currencies
// ("доллар" belongs to USD, not TTD which lists it too and sorts earlier). // Major currencies register first so they win generic phrases
function registerCurrencies(reg: Registry): void { // ("доллар" belongs to USD, not TTD which lists it too and sorts earlier).
const PRIORITY = [ const PRIORITY = [
"USD", "EUR", "GBP", "RUB", "JPY", "CNY", "CHF", "CAD", "AUD", "UAH", "USD", "EUR", "GBP", "RUB", "JPY", "CNY", "CHF", "CAD", "AUD", "UAH",
"BYN", "KZT", "PLN", "TRY", "INR", "BRL", "KRW", "NZD", "SEK", "NOK", "BYN", "KZT", "PLN", "TRY", "INR", "BRL", "KRW", "NZD", "SEK", "NOK",
@ -405,10 +373,8 @@ function registerCurrencies(reg: Registry): void {
if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code); if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code);
for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false }); for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });
} }
}
// speed, pressure, energy, power, frequency, fuel // speed, pressure, energy, power, frequency, fuel
function registerExtraUnits(reg: Registry): void {
for (const d of EXTRA_UNITS) { for (const d of EXTRA_UNITS) {
const unit: Unit = { const unit: Unit = {
id: d.id, id: d.id,
@ -429,11 +395,9 @@ function registerExtraUnits(reg: Registry): void {
} }
} }
} }
}
// live prices come from the shell; codes that collide with common words // cryptocurrencies (live prices come from the shell)
// (TON, NEAR, DOT, ...) match case-sensitively only // codes that collide with common words match case-sensitively only
function registerCrypto(reg: Registry): void {
const CRYPTO_STRICT_CODES = new Set(["TON", "NEAR", "ATOM", "LINK", "UNI", "DOT", "SOL"]); const CRYPTO_STRICT_CODES = new Set(["TON", "NEAR", "ATOM", "LINK", "UNI", "DOT", "SOL"]);
for (const c of CRYPTO) { for (const c of CRYPTO) {
reg.currencyCodes.add(c.code); reg.currencyCodes.add(c.code);
@ -444,9 +408,8 @@ function registerCrypto(reg: Registry): void {
if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false }); if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });
} }
} }
}
function registerCompletions(reg: Registry): void { // autocomplete entries
const seen = new Set<string>(); const seen = new Set<string>();
const addCompletion = (label: string, type: Completion["type"], detail?: string) => { const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
const k = `${type}:${label.toLowerCase()}`; const k = `${type}:${label.toLowerCase()}`;
@ -465,4 +428,6 @@ function registerCompletions(reg: Registry): void {
for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) { for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
addCompletion(k, "keyword"); addCompletion(k, "keyword");
} }
return reg;
} }

View file

@ -1,92 +0,0 @@
import { describe, it, expect, beforeAll } from "vitest";
import { tokenize } from "./tokenizer";
import { buildRegistry, Registry } from "./registry";
describe("tokenizer", () => {
let reg: Registry;
beforeAll(() => {
reg = buildRegistry();
});
it("tokenizes numbers and basic operators", () => {
const tokens = tokenize("1 + 2 * 3.5 - 4 / 2 ^ 3", reg);
expect(tokens.map(t => t.t)).toEqual([
"num", "op", "num", "op", "num", "op", "num", "op", "num", "op", "num"
]);
});
it("handles dates", () => {
const tokens = tokenize("2024-01-01 + 5 days", reg);
expect(tokens[0].t).toBe("datelit");
expect(tokens.map(t => t.t)).toEqual(["datelit", "op", "num", "unit"]);
});
it("handles xrefs", () => {
const tokens = tokenize("@Sheet.key", reg);
expect(tokens[0].t).toBe("xref");
if (tokens[0].t === "xref") {
expect(tokens[0].sheet).toBe("Sheet");
expect(tokens[0].key).toBe("key");
}
});
it("handles bitwise operators", () => {
const tokens = tokenize("5 << 1 >> 2 & 3 | 4", reg);
const typesAndOps = tokens.map(t => t.t === "bitop" ? `bitop:${t.op}` : t.t);
expect(typesAndOps).toEqual([
"num", "bitop:shl", "num", "bitop:shr", "num", "bitop:band", "num", "bitop:bor", "num"
]);
});
it("handles registry matches like units and currencies", () => {
const tokens = tokenize("100 USD to EUR", reg);
const types = tokens.map(t => t.t);
expect(types).toEqual(["num", "currency", "conv", "currency"]);
});
it("disambiguates 'in' as inches", () => {
const tokens = tokenize("5 ft 4 in in cm", reg);
expect(tokens.map(t => t.t)).toEqual(["num", "unit", "num", "unit", "conv", "unit"]);
if (tokens[3].t === "unit") {
expect(tokens[3].unit.id).toBe("inch");
}
});
it("does not disambiguate 'in' when it is not inches", () => {
const tokens = tokenize("in cm", reg);
expect(tokens.map(t => t.t)).toEqual(["conv", "unit"]);
});
it("handles other registry constructs (funcs, aggs, etc)", () => {
const tokens = tokenize("sum(1, 2) + sin(pi) %", reg);
expect(tokens.map(t => t.t)).toEqual([
"agg", "lparen", "num", "junk", "num", "rparen", "op", "func", "lparen", "const", "rparen", "percent"
]);
});
it("handles scale and repr", () => {
const tokens = tokenize("1M in hex", reg);
expect(tokens.map(t => t.t)).toEqual(["num", "scale", "conv", "repr"]);
});
it("handles pctop and date words", () => {
const tokens = tokenize("5% of 100 today", reg);
expect(tokens.map(t => t.t)).toEqual(["num", "percent", "pctop", "num", "date"]);
});
it("handles bang, semicolon, unknown", () => {
const tokens = tokenize("! ; ?", reg);
expect(tokens.map(t => t.t)).toEqual(["bang", "semicolon", "unknown"]);
});
it("handles special tokens", () => {
const tokens = tokenize("unix date", reg);
expect(tokens.map(t => t.t)).toEqual(["special", "special"]);
});
it("handles assign", () => {
const tokens = tokenize("x = 5", reg);
expect(tokens.map(t => t.t)).toEqual(["word", "assign", "num"]);
});
});

View file

@ -1,5 +1,5 @@
// Lexes a line and resolves phrases through the registry into semantic tokens. // Lexes a line and resolves phrases through the registry into semantic tokens.
import { DateOrder, Decimal, NumeralRepr, Unit } from "./types"; import { Decimal, NumeralRepr, Unit } from "./types";
import { lexLine } from "./lexer"; import { lexLine } from "./lexer";
import { Registry, PctOp, DateWord, BitOp } from "./registry"; import { Registry, PctOp, DateWord, BitOp } from "./registry";
@ -15,23 +15,23 @@ export type Token =
| { t: "pctop"; op: PctOp; start: number; end: number } | { t: "pctop"; op: PctOp; start: number; end: number }
| { t: "percent"; start: number; end: number } | { t: "percent"; start: number; end: number }
| { t: "func"; name: string; start: number; end: number } | { t: "func"; name: string; start: number; end: number }
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart"; start: number; end: number } | { t: "agg"; name: "sum" | "avg" | "prev"; start: number; end: number }
| { t: "scale"; mult: Decimal; start: number; end: number } | { t: "scale"; mult: Decimal; start: number; end: number }
| { t: "repr"; repr: NumeralRepr; start: number; end: number } | { t: "repr"; repr: NumeralRepr; start: number; end: number }
| { t: "date"; word: DateWord; start: number; end: number } | { t: "date"; word: DateWord; start: number; end: number }
| { t: "datelit"; ms: number; start: number; end: number }
| { t: "xref"; sheet: string; key: string; start: number; end: number }
| { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number } | { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
| { t: "lparen"; start: number; end: number } | { t: "lparen"; start: number; end: number }
| { t: "rparen"; start: number; end: number } | { t: "rparen"; start: number; end: number }
| { t: "semicolon"; start: number; end: number } | { t: "semicolon"; start: number; end: number }
| { t: "bang"; start: number; end: number } | { t: "bang"; start: number; end: number }
| { t: "word"; raw: string; start: number; end: number } | { t: "word"; raw: string; start: number; end: number }
| { t: "unknown"; start: number; end: number }
| { t: "junk"; raw: string; start: number; end: number }; | { t: "junk"; raw: string; start: number; end: number };
export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dmy"): Token[] { const SYM_OPS: Record<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "pow"> = {};
const lexes = lexLine(line, dateOrder);
export function tokenize(line: string, reg: Registry): Token[] {
void SYM_OPS;
const lexes = lexLine(line);
const lowers = lexes.map((l) => l.raw.toLowerCase()); const lowers = lexes.map((l) => l.raw.toLowerCase());
const tokens: Token[] = []; const tokens: Token[] = [];
let i = 0; let i = 0;
@ -45,18 +45,6 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
continue; continue;
} }
if (lx.type === "date") {
tokens.push({ t: "datelit", ms: lx.dateMs!, ...span });
i++;
continue;
}
if (lx.type === "xref") {
tokens.push({ t: "xref", sheet: lx.sheet!, key: lx.key!, ...span });
i++;
continue;
}
const m = reg.match(lexes, lowers, i); const m = reg.match(lexes, lowers, i);
if (m) { if (m) {
const end = lexes[i + m.length - 1].end; const end = lexes[i + m.length - 1].end;
@ -105,7 +93,6 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
case "=": tokens.push({ t: "assign", ...span }); break; case "=": tokens.push({ t: "assign", ...span }); break;
case ";": tokens.push({ t: "semicolon", ...span }); break; case ";": tokens.push({ t: "semicolon", ...span }); break;
case "!": tokens.push({ t: "bang", ...span }); break; case "!": tokens.push({ t: "bang", ...span }); break;
case "?": tokens.push({ t: "unknown", ...span }); break;
default: tokens.push({ t: "junk", raw: lx.raw, ...span }); default: tokens.push({ t: "junk", raw: lx.raw, ...span });
} }
i++; i++;
@ -115,7 +102,7 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
tokens.push({ t: "word", raw: lx.raw, ...span }); tokens.push({ t: "word", raw: lx.raw, ...span });
i++; i++;
} }
return disambiguateMinAgg(disambiguateIn(tokens, reg, line), reg, line); return disambiguateIn(tokens, reg, line);
} }
/** /**
@ -138,21 +125,3 @@ function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
return tk; return tk;
}); });
} }
/**
* "5 min": the "min" aggregate keyword (registered first, so it wins the
* phrase in the registry) collides with the minute unit's "min"/"мин"
* abbreviation. Right after a number it can only mean minutes — the
* standalone `min` aggregate line has no preceding number to collide with.
*/
function disambiguateMinAgg(tokens: Token[], reg: Registry, line: string): Token[] {
const minute = reg.unitsById.get("minute");
if (!minute) return tokens;
return tokens.map((tk, i) => {
if (tk.t !== "agg" || tk.name !== "min") return tk;
const raw = line.slice(tk.start, tk.end).toLowerCase();
if (raw !== "min" && raw !== "мин") return tk;
if (tokens[i - 1]?.t !== "num") return tk;
return { t: "unit", unit: minute, start: tk.start, end: tk.end } as Token;
});
}

View file

@ -1,41 +0,0 @@
import { describe, it, expect } from "vitest";
import { qty, pct, Decimal } from "./types";
describe("types", () => {
describe("qty", () => {
it("creates a quantity with default arguments", () => {
const result = qty(42);
expect(result.kind).toBe("quantity");
expect(result.value).toBeInstanceOf(Decimal);
expect(result.value.toNumber()).toBe(42);
expect(result.unit).toBeNull();
expect(result.repr).toBe("decimal");
});
it("creates a quantity with all arguments provided", () => {
const mockUnit = { id: "meter", dimension: "length", ratio: new Decimal(1), format: "m" } as any;
const result = qty("3.14", mockUnit, "hex");
expect(result.kind).toBe("quantity");
expect(result.value).toBeInstanceOf(Decimal);
expect(result.value.toString()).toBe("3.14");
expect(result.unit).toBe(mockUnit);
expect(result.repr).toBe("hex");
});
});
describe("pct", () => {
it("creates a percent value from number", () => {
const result = pct(25);
expect(result.kind).toBe("percent");
expect(result.value).toBeInstanceOf(Decimal);
expect(result.value.toNumber()).toBe(25);
});
it("creates a percent value from string", () => {
const result = pct("12.5");
expect(result.kind).toBe("percent");
expect(result.value).toBeInstanceOf(Decimal);
expect(result.value.toString()).toBe("12.5");
});
});
});

View file

@ -4,11 +4,6 @@ Decimal.set({ precision: 40, toExpNeg: -9, toExpPos: 21 });
export { Decimal }; export { Decimal };
/** Shared here (not in evaluator.ts) so unitdata.ts can use it too without a cycle. */
export const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
export const DEC_ZERO = new Decimal(0);
export const DEC_ONE = new Decimal(1);
export type Dimension = export type Dimension =
| "scalar" | "scalar"
| "length" | "length"
@ -29,12 +24,6 @@ export type Dimension =
export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain"; export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
/** day-first (31.12.2024) or month-first (12/31/2024) for ambiguous date literals */
export type DateOrder = "dmy" | "mdy";
/** "system" renders via the OS locale; the rest force a fixed layout */
export type DateFormat = "system" | "iso" | "dmy" | "mdy";
/** A resolved unit instance attached to a value. */ /** A resolved unit instance attached to a value. */
export interface Unit { export interface Unit {
/** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */ /** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
@ -73,13 +62,7 @@ export interface DateVal {
timeZone?: string; timeZone?: string;
} }
export interface ChartValue { export type Value = Quantity | PercentVal | DateVal;
kind: "chart";
points: Decimal[];
unitLabel: string | null;
}
export type Value = Quantity | PercentVal | DateVal | ChartValue;
export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity { export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity {
return { kind: "quantity", value: new Decimal(value), unit, repr }; return { kind: "quantity", value: new Decimal(value), unit, repr };
@ -90,7 +73,6 @@ export function pct(value: Decimal.Value): PercentVal {
} }
export class EvalError extends Error {} export class EvalError extends Error {}
export class XRefError extends EvalError {}
export interface EngineSettings { export interface EngineSettings {
/** max decimal places shown */ /** max decimal places shown */
@ -99,13 +81,10 @@ export interface EngineSettings {
groupSeparator: string; groupSeparator: string;
/** decimal separator shown in output */ /** decimal separator shown in output */
decimalSeparator: string; decimalSeparator: string;
/** date display layout; also disambiguates 01/02/2024 on input */
dateFormat: DateFormat;
} }
export const defaultSettings: EngineSettings = { export const defaultSettings: EngineSettings = {
precision: 2, precision: 2,
groupSeparator: ",", groupSeparator: ",",
decimalSeparator: ".", decimalSeparator: ".",
dateFormat: "system",
}; };

View file

@ -1,7 +1,7 @@
// Conversion ratios. Base units per dimension: length=meter, area=m², // Conversion ratios. Base units per dimension: length=meter, area=m²,
// volume=m³, weight=gram, time=second, angle=radian, data=bit, // volume=m³, weight=gram, time=second, angle=radian, data=bit,
// temperature=kelvin (ratio+offset). // temperature=kelvin (ratio+offset).
import { Decimal, Dimension, PI } from "./types"; import { Decimal, Dimension } from "./types";
export interface UnitData { export interface UnitData {
id: string; id: string;
@ -79,7 +79,7 @@ export const UNIT_DATA: UnitData[] = [
// Angle (base: radian) // Angle (base: radian)
{ id: "radian", category: "Angle", dimension: "angle", ratio: "1" }, { id: "radian", category: "Angle", dimension: "angle", ratio: "1" },
{ id: "degree", category: "Angle", dimension: "angle", ratio: PI.div(180).toString() }, { id: "degree", category: "Angle", dimension: "angle", ratio: String(Math.PI / 180) },
// Data (base: bit) // Data (base: bit)
{ id: "bit", category: "Data", dimension: "data", ratio: "1", prefixes: "data" }, { id: "bit", category: "Data", dimension: "data", ratio: "1", prefixes: "data" },

View file

@ -169,13 +169,11 @@ export const EN: VocabLocale = {
"KES.format": "%@ KSh", "KES.format": "%@ KSh",
"KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh", "KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh",
"KGS.format": "%@ sом", "KGS.format": "%@ sом",
// bare "Som"/"Soms" dropped — UZS (larger economy) owns it; say "Kyrgyzstani Som" for KGS "KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,Soms,Som,sом",
"KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,sом",
"KHR.format": "%@ R.", "KHR.format": "%@ R.",
"KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels", "KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels",
"KMF.format": "%@ FC", "KMF.format": "%@ FC",
// bare "FC" dropped — CDF (Congolese franc, registered first) owns it; say "Comorian Franc" for KMF "KMF.variants": "Comorian Francs,Comorian Franc,FC",
"KMF.variants": "Comorian Francs,Comorian Franc",
"KPW.format": "₩ %@", "KPW.format": "₩ %@",
"KPW.variants": "North Korean won", "KPW.variants": "North Korean won",
"KRW.format": "₩ %@", "KRW.format": "₩ %@",
@ -223,8 +221,7 @@ export const EN: VocabLocale = {
"MVR.format": "Rf %@", "MVR.format": "Rf %@",
"MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf", "MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf",
"MWK.format": "%@ MK", "MWK.format": "%@ MK",
// bare "Kwacha" dropped — ZMW (larger economy) owns it; say "Malawian Kwacha" for MWK "MWK.variants": "Malawian Kwacha,Kwacha",
"MWK.variants": "Malawian Kwacha",
"MXN.format": "$ %@", "MXN.format": "$ %@",
"MXN.variants": "Mexican Peso,Mexican Pesos", "MXN.variants": "Mexican Peso,Mexican Pesos",
"MYR.format": "RM %@", "MYR.format": "RM %@",
@ -304,8 +301,7 @@ export const EN: VocabLocale = {
"TJS.format": "%@ TJS", "TJS.format": "%@ TJS",
"TJS.variants": "Tajikistani Somoni,Somoni", "TJS.variants": "Tajikistani Somoni,Somoni",
"TMT.format": "%@ TMT", "TMT.format": "%@ TMT",
// bare "Manat" dropped — AZN (registered first) owns it; say "Turkmenistan Manat" for TMT "TMT.variants": "Turkmenistan Manat,Manat",
"TMT.variants": "Turkmenistan Manat",
"TND.format": "%@ TD", "TND.format": "%@ TD",
"TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD", "TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD",
"TOP.format": "T$ %@", "TOP.format": "T$ %@",
@ -414,7 +410,7 @@ export const EN: VocabLocale = {
"decimal.variants": "decimal,dec", "decimal.variants": "decimal,dec",
"hex.variants": "hex", "hex.variants": "hex",
"octal.variants": "octal", "octal.variants": "octal",
"scientific.variants": "scientific, sci, exponent, exponential" "scientific.variants": "scientific, sci, exp, exponent, exponential"
}, },
"Operations": { "Operations": {
"assignment.variants": "equal,is", "assignment.variants": "equal,is",
@ -431,15 +427,7 @@ export const EN: VocabLocale = {
"ln.variants": "natural log,natural logarithm", "ln.variants": "natural log,natural logarithm",
"log.variants": "logarithm", "log.variants": "logarithm",
"round.variants": "rounded", "round.variants": "rounded",
"sum.variants": "sum,total", "sum.variants": "sum,total"
"count.variants": "count,how many",
"min.variants": "min,minimum,smallest",
"max.variants": "max,maximum,largest",
"product.variants": "product",
"random.variants": "random,rand",
"chart.variants": "chart,spark,sparkline,graph",
"until.variants": "until,days until,day until",
"since.variants": "since,days since,day since"
}, },
"Percent": { "Percent": {
"as_a_percent_of.variants": "as a % of,as a percent of", "as_a_percent_of.variants": "as a % of,as a percent of",
@ -737,8 +725,7 @@ export const RU: VocabLocale = {
"MUR.variants": "Маврикийская рупия,Маврикийских рупий,Маврикийских рупиях", "MUR.variants": "Маврикийская рупия,Маврикийских рупий,Маврикийских рупиях",
"MVR.format": "%@ Rf", "MVR.format": "%@ Rf",
"MVR.variants": "Мальдивская руфия,Руфия,Руфий,Руфиях", "MVR.variants": "Мальдивская руфия,Руфия,Руфий,Руфиях",
// bare "Квача"/"Квачах" dropped — ZMW (larger economy) owns them; the fuller phrases stay unambiguous "MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах,Квача,Квачах",
"MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах",
"MXN.format": "%@ $", "MXN.format": "%@ $",
"MXN.variants": "Мексиканских песо,Мексиканское песо", "MXN.variants": "Мексиканских песо,Мексиканское песо",
"MYR.format": "%@ RM", "MYR.format": "%@ RM",
@ -798,8 +785,7 @@ export const RU: VocabLocale = {
"THB.variants": "Тайский бат,Бат,Батов,Батах", "THB.variants": "Тайский бат,Бат,Батов,Батах",
"TJS.format": "%@ смн.", "TJS.format": "%@ смн.",
"TJS.variants": "Таджикский сомони,Сомони", "TJS.variants": "Таджикский сомони,Сомони",
// bare "Манат" dropped — AZN owns it; the fuller phrases below stay unambiguous "TMT.variants": "Туркменский манат,Новый туркменский манат,Манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
"TMT.variants": "Туркменский манат,Новый туркменский манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
"TND.variants": "Тунисские Динары,Тунисский Динар,Тунисских Динаров,Тунисских Динарах", "TND.variants": "Тунисские Динары,Тунисский Динар,Тунисских Динаров,Тунисских Динарах",
"TOP.format": "%@ T$", "TOP.format": "%@ T$",
"TOP.variants": "Тонганская паанга,Паанга,Паангах", "TOP.variants": "Тонганская паанга,Паанга,Паангах",
@ -913,15 +899,7 @@ export const RU: VocabLocale = {
"ln.variants": "натуральный логарифм,нат. логарифм", "ln.variants": "натуральный логарифм,нат. логарифм",
"log.variants": "логарифм", "log.variants": "логарифм",
"round.variants": "округлить,округление", "round.variants": "округлить,округление",
"sum.variants": "всего,сумма,итог,итого", "sum.variants": "всего,сумма,итог,итого"
"count.variants": "количество,кол-во,счёт,число строк",
"min.variants": "минимум,мин,наименьшее",
"max.variants": "максимум,макс,наибольшее",
"product.variants": "произведение,произв",
"random.variants": "случайное,случайно,рандом",
"chart.variants": "график,спарклайн,диаграмма",
"until.variants": "осталось до,дней до,дней до даты",
"since.variants": "прошло с,дней с,дней прошло с"
}, },
"Percent": { "Percent": {
"as_a_percent_of.variants": "как процент от,как процент", "as_a_percent_of.variants": "как процент от,как процент",

View file

@ -1,102 +0,0 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("./vocab-data", () => ({
EN: {
"TestCategory": {
"test_key.variants": "a, b, c ,a, ",
"test_key.format": "en_fmt",
"test_key.suffix1": "val1",
"test_key2.variants": "x,y",
"test_key2.suffix1": "val2",
"same.key": "duplicate",
}
},
RU: {
"TestCategory": {
"test_key.variants": "d, b , e",
"test_key.format": "ru_fmt",
"test_key3.variants": "z",
"test_key3.suffix2": "val3",
"same.key": "duplicate",
}
}
}));
import { variants, entry, entryEn, entriesAll, idsOf, idsWithSuffix } from "./vocab";
describe("vocab", () => {
describe("variants", () => {
it("returns comma-split, trimmed, and deduped variants across locales", () => {
// EN has "a", "b", "c", "a", "" (empty string trimmed out)
// RU has "d", "b", "e"
const res = variants("TestCategory", "test_key.variants");
expect(res).toEqual(["a", "b", "c", "d", "e"]);
});
it("returns empty array for unknown category or key", () => {
expect(variants("UnknownCategory", "test_key.variants")).toEqual([]);
expect(variants("TestCategory", "unknown_key.variants")).toEqual([]);
});
});
describe("entry", () => {
it("returns the first locale's value", () => {
expect(entry("TestCategory", "test_key.format")).toBe("en_fmt");
});
it("returns undefined for unknown category or key", () => {
expect(entry("UnknownCategory", "test_key.format")).toBeUndefined();
expect(entry("TestCategory", "unknown_key")).toBeUndefined();
});
});
describe("entryEn", () => {
it("returns only the English value", () => {
expect(entryEn("TestCategory", "test_key.format")).toBe("en_fmt");
});
it("returns undefined if only in RU", () => {
expect(entryEn("TestCategory", "test_key3.variants")).toBeUndefined();
});
});
describe("entriesAll", () => {
it("returns all unique values across locales", () => {
expect(entriesAll("TestCategory", "test_key.format")).toEqual(["en_fmt", "ru_fmt"]);
});
it("deduplicates values if they are the same in multiple locales", () => {
expect(entriesAll("TestCategory", "same.key")).toEqual(["duplicate"]);
});
it("returns empty array for unknown category or key", () => {
expect(entriesAll("UnknownCategory", "test_key.format")).toEqual([]);
});
});
describe("idsOf", () => {
it("returns all base keys ending in .variants across locales", () => {
const res = idsOf("TestCategory");
expect(res.sort()).toEqual(["test_key", "test_key2", "test_key3"].sort());
});
it("returns empty array for unknown category", () => {
expect(idsOf("UnknownCategory")).toEqual([]);
});
});
describe("idsWithSuffix", () => {
it("returns all keys ending with the given suffix across locales", () => {
const res1 = idsWithSuffix("TestCategory", ".suffix1");
expect(res1.sort()).toEqual(["test_key", "test_key2"].sort());
const res2 = idsWithSuffix("TestCategory", ".suffix2");
expect(res2).toEqual(["test_key3"]);
});
it("returns empty array for unknown category or suffix", () => {
expect(idsWithSuffix("UnknownCategory", ".suffix1")).toEqual([]);
expect(idsWithSuffix("TestCategory", ".unknown_suffix")).toEqual([]);
});
});
});

View file

@ -1,207 +0,0 @@
import { logger } from "./logger";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { makeApi, runExtensions } from "./extensions";
import { SumEngine } from "./engine";
describe("extensions", () => {
let mockEngine: any;
beforeEach(() => {
mockEngine = {
setVariable: vi.fn(),
addUnit: vi.fn(),
addFunction: vi.fn(),
};
});
describe("makeApi", () => {
it("delegates setVariable to engine", () => {
const api = makeApi(mockEngine as SumEngine);
api.setVariable("myVar", 42);
expect(mockEngine.setVariable).toHaveBeenCalledWith("myVar", 42);
});
it("delegates addUnit to engine", () => {
const api = makeApi(mockEngine as SumEngine);
const spec = { id: "testUnit", phrases: "tests", baseUnitId: "USD", ratio: 1 };
api.addUnit(spec);
expect(mockEngine.addUnit).toHaveBeenCalledWith(spec);
});
it("delegates addFunction to engine", () => {
const api = makeApi(mockEngine as SumEngine);
const spec = { id: "testFunc", phrases: "test" };
const fn = () => 42;
api.addFunction(spec, fn);
expect(mockEngine.addFunction).toHaveBeenCalledWith(spec, fn);
});
});
describe("runExtensions", () => {
let consoleErrorSpy: any;
let consoleWarnSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
vi.resetModules();
vi.doUnmock("quickjs-emscripten-core");
});
it("runs scripts successfully using numi api", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "script1", code: "numi.setVariable('var1', 10);" },
{ name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" },
]);
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
expect(mockEngine.addUnit).toHaveBeenCalledWith({
id: "unit1",
phrases: "u",
baseUnitId: "USD",
ratio: 1,
});
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("swallows errors and logs to console.error", async () => {
await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]);
expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error));
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error");
});
it("continues running other scripts if one fails", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "script1", code: "numi.setVariable('var1', 10);" },
{ name: "script2", code: "throw new Error('boom');" },
{ name: "script3", code: "numi.setVariable('var3', 30);" },
]);
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
it("passes unit-carrying values through setVariable", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "s", code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });" },
]);
expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { double: 1200, unitId: "USD" });
});
it("rejects malformed api arguments as a script error", async () => {
await runExtensions(mockEngine as SumEngine, [{ name: "bad", code: "numi.setVariable('x', 'nope');" }]);
expect(mockEngine.setVariable).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("numi.setVariable");
});
it("registers a function callable with one array of values", async () => {
await runExtensions(mockEngine as SumEngine, [
{
name: "hyp",
code: "numi.addFunction({ id: 'hyp', phrases: 'hyp' }, function (values) { return { double: Math.hypot(values[0].double, values[1].double) }; });",
},
]);
expect(mockEngine.addFunction).toHaveBeenCalledWith({ id: "hyp", phrases: "hyp" }, expect.any(Function));
const fn = mockEngine.addFunction.mock.calls[0][1];
expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 });
expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 });
});
it("marshals unitId into the sandbox and back out", async () => {
await runExtensions(mockEngine as SumEngine, [
{
name: "echo",
code: "numi.addFunction({ id: 'echo', phrases: 'echo' }, function (v) { return { double: v[0].double * 2, unitId: v[0].unitId }; });",
},
]);
const fn = mockEngine.addFunction.mock.calls[0][1];
expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ double: 10, unitId: "meter" });
});
it("has no host globals inside the sandbox", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');" },
]);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("interrupts an infinite loop at load time", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "spin", code: "while (true) {}" },
{ name: "after", code: "numi.setVariable('ok', 1);" },
]);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:");
// the runtime survives the interrupt and keeps loading scripts
expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1);
});
it("interrupts a hung function on every call, not just the first", async () => {
await runExtensions(mockEngine as SumEngine, [
{
name: "spin",
code: "numi.addFunction({ id: 'spin', phrases: 'spin' }, function () { while (true) {} });",
},
{
name: "good",
code: "numi.addFunction({ id: 'good', phrases: 'good' }, function (v) { return v[0].double + 1; });",
},
]);
const spin = mockEngine.addFunction.mock.calls[0][1];
const good = mockEngine.addFunction.mock.calls[1][1];
expect(() => spin([])).toThrow(/spin/);
// deadline is re-armed per call: a second call still times out rather than
// failing instantly, and an unrelated function still works afterwards
expect(() => spin([])).toThrow(/spin/);
expect(good([{ double: 1 }])).toBe(2);
});
it("surfaces a bad return value as a function error", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "bad", code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });" },
]);
const fn = mockEngine.addFunction.mock.calls[0][1];
expect(() => fn([])).toThrow(/bad/);
});
it("skips extensions when the sandbox fails to load", async () => {
vi.resetModules();
vi.doMock("quickjs-emscripten-core", () => ({
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
}));
const { runExtensions: run } = await import("./extensions");
const { logger } = await import("./logger");
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
expect(mockEngine.setVariable).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith(
"extensions disabled: QuickJS sandbox unavailable:",
expect.any(Error),
);
});
it("does not load the sandbox when there are no scripts", async () => {
await runExtensions(mockEngine as SumEngine, []);
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
});

View file

@ -1,21 +1,11 @@
import { logger } from "./logger"; // JS extensions runtime: numi.setVariable,
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction. // numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
import {
newQuickJSWASMModuleFromVariant,
QuickJSContext,
QuickJSHandle,
} from "quickjs-emscripten-core";
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
interface ExtensionApi { export interface ExtensionApi {
setVariable(name: string, value: number | ExtensionValue): void; setVariable(name: string, value: number | ExtensionValue): void;
addUnit(spec: ExtensionUnitSpec): void; addUnit(spec: ExtensionUnitSpec): void;
addFunction( addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void;
spec: { id: string; phrases: string },
fn: (values: ExtensionValue[]) => ExtensionValue | number,
): void;
} }
export function makeApi(engine: SumEngine): ExtensionApi { export function makeApi(engine: SumEngine): ExtensionApi {
@ -26,209 +16,14 @@ export function makeApi(engine: SumEngine): ExtensionApi {
}; };
} }
// 32 MB is orders of magnitude more than a sheet helper needs, and small enough export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
// that a runaway allocation fails inside the sandbox instead of the host heap. const numi = makeApi(engine);
const MEMORY_LIMIT = 32 * 1024 * 1024;
// 512 KB — unbounded recursion hits this and throws in the sandbox, never on the JS stack.
const STACK_SIZE = 512 * 1024;
// One-off cost at startup, so it can be generous.
const LOAD_TIMEOUT_MS = 1000;
// Registered functions are called synchronously from evaluateDocument, i.e. on
// every keystroke for every line that uses them — this budget must stay small.
const CALL_TIMEOUT_MS = 50;
const now = () =>
typeof performance !== "undefined" ? performance.now() : Date.now();
function toExtValue(v: unknown, what: string): ExtensionValue | number {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (v && typeof v === "object") {
const o = v as { double?: unknown; unitId?: unknown };
if (typeof o.double === "number" && Number.isFinite(o.double)) {
return typeof o.unitId === "string"
? { double: o.double, unitId: o.unitId }
: { double: o.double };
}
}
throw new Error(`${what}: expected a number or { double, unitId? }`);
}
function message(e: unknown): string {
if (e instanceof Error) return e.message;
if (
e &&
typeof e === "object" &&
typeof (e as { message?: unknown }).message === "string"
) {
return (e as { message: string }).message;
}
return String(e);
}
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure.
// Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app.
export async function runExtensions(
engine: SumEngine,
scripts: { name: string; code: string }[],
): Promise<void> {
if (scripts.length === 0) return;
try {
await load(engine, scripts);
} catch (e) {
logger.warn("extensions disabled: QuickJS sandbox unavailable:", e);
}
}
type RunWithTimeout = <T>(timeoutMs: number, fn: () => T) => T;
function callGuest(
vm: QuickJSContext,
id: string,
fn: QuickJSHandle,
values: ExtensionValue[],
runWithTimeout: RunWithTimeout,
): ExtensionValue | number {
// extension-sample.js convention: the callback takes one array of values.
const arr = vm.newArray();
try {
values.forEach((v, i) => {
const o = vm.newObject();
const d = vm.newNumber(v.double);
vm.setProp(o, "double", d);
d.dispose();
if (v.unitId) {
const u = vm.newString(v.unitId);
vm.setProp(o, "unitId", u);
u.dispose();
}
vm.setProp(arr, i, o);
o.dispose();
});
const res = runWithTimeout(CALL_TIMEOUT_MS, () =>
vm.callFunction(fn, vm.undefined, [arr]),
);
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
throw new Error(`${id}: ${message(err)}`);
}
const out = vm.dump(res.value);
res.value.dispose();
return toExtValue(out, id);
} finally {
arr.dispose();
}
}
function exposeApi(
vm: QuickJSContext,
api: ExtensionApi,
runWithTimeout: RunWithTimeout,
) {
const numi = vm.newObject();
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
const fn = vm.newFunction(name, (...args) => {
try {
impl(...args);
} catch (e) {
return { error: vm.newError(`numi.${name}: ${message(e)}`) };
}
});
vm.setProp(numi, name, fn);
fn.dispose();
};
define("setVariable", (nameHandle, valueHandle) => {
const name = vm.dump(nameHandle);
if (typeof name !== "string" || !name)
throw new Error("expects a variable name");
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
});
define("addUnit", (specHandle) => {
const spec = vm.dump(specHandle);
if (
!spec ||
typeof spec.id !== "string" ||
typeof spec.baseUnitId !== "string" ||
typeof spec.ratio !== "number"
) {
throw new Error("expects { id, phrases, baseUnitId, ratio }");
}
api.addUnit({
id: spec.id,
phrases: typeof spec.phrases === "string" ? spec.phrases : spec.id,
baseUnitId: spec.baseUnitId,
...(typeof spec.format === "string" ? { format: spec.format } : {}),
ratio: spec.ratio,
});
});
define("addFunction", (specHandle, fnHandle) => {
const spec = vm.dump(specHandle);
if (!spec || typeof spec.id !== "string")
throw new Error("expects { id, phrases }");
if (!fnHandle || vm.typeof(fnHandle) !== "function")
throw new Error("expects a function");
// outlives this callback, so it needs its own reference; the context is never disposed
const guestFn = fnHandle.dup();
const id = spec.id;
api.addFunction(
{ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id },
(values) => callGuest(vm, id, guestFn, values, runWithTimeout),
);
});
vm.setProp(vm.global, "numi", numi);
numi.dispose();
}
function evaluateScripts(
vm: QuickJSContext,
scripts: { name: string; code: string }[],
runWithTimeout: RunWithTimeout,
) {
for (const s of scripts) { for (const s of scripts) {
try { try {
const res = runWithTimeout(LOAD_TIMEOUT_MS, () => const fn = new Function("numi", s.code);
vm.evalCode(s.code, s.name), fn(numi);
);
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
throw new Error(message(err));
}
res.value.dispose();
} catch (e) { } catch (e) {
logger.error(`extension ${s.name} failed:`, e); console.error(`extension ${s.name} failed:`, e);
} }
} }
} }
async function load(
engine: SumEngine,
scripts: { name: string; code: string }[],
): Promise<void> {
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
const vm: QuickJSContext = mod.newContext();
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
vm.runtime.setMaxStackSize(STACK_SIZE);
// Armed before every entry into the sandbox and disarmed after, so a hung script
// at load time and a hung function call on keystroke N are both bounded.
let deadline = Infinity;
vm.runtime.setInterruptHandler(() => now() > deadline);
const runWithTimeout: RunWithTimeout = (timeoutMs, fn) => {
deadline = now() + timeoutMs;
try {
return fn();
} finally {
deadline = Infinity;
}
};
exposeApi(vm, makeApi(engine), runWithTimeout);
evaluateScripts(vm, scripts, runWithTimeout);
}

View file

@ -1,61 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { setLang, t, detectLang } from "./i18n";
describe("i18n", () => {
beforeEach(() => {
const mockElements = [
{ dataset: { i18n: "settings" }, textContent: "" },
{ dataset: { i18n: "theme" }, textContent: "" }
];
vi.stubGlobal("document", {
documentElement: { lang: "" },
querySelectorAll: vi.fn().mockReturnValue(mockElements)
});
vi.stubGlobal("navigator", { language: "en-US" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("sets language and updates document lang", () => {
setLang("ru");
expect(t("settings")).toBe("Настройки");
expect(document.documentElement.lang).toBe("ru");
// Check if querySelectorAll was called and elements were updated
const elements = document.querySelectorAll("[data-i18n]");
expect(elements[0].textContent).toBe("Настройки");
expect(elements[1].textContent).toBe("Тема");
setLang("en");
expect(t("settings")).toBe("Settings");
expect(document.documentElement.lang).toBe("en");
});
it("falls back to en for unknown languages", () => {
setLang("fr");
expect(t("settings")).toBe("Settings");
expect(document.documentElement.lang).toBe("en");
});
it("falls back to en dictionary for missing keys in ru", () => {
// If a key exists in en but not ru, it should fallback to en
// we don't have one right now, but we can test missing keys in both
expect(t("unknown_key")).toBe("unknown_key");
});
it("detects language from navigator", () => {
vi.stubGlobal("navigator", { language: "ru-RU" });
expect(detectLang()).toBe("ru");
vi.stubGlobal("navigator", { language: "en-US" });
expect(detectLang()).toBe("en");
vi.stubGlobal("navigator", { language: undefined });
expect(detectLang()).toBe("en");
});
});

View file

@ -27,7 +27,6 @@ const dict: Record<string, Record<string, string>> = {
resultswidth: "Results column", resultswidth: "Results column",
selection: "Selection", selection: "Selection",
rates: "Rates", rates: "Rates",
market: "Market",
justNow: "just now", justNow: "just now",
minAgo: "{}m ago", minAgo: "{}m ago",
hourAgo: "{}h ago", hourAgo: "{}h ago",
@ -56,26 +55,6 @@ const dict: Record<string, Record<string, string>> = {
updateFailed: "Update failed", updateFailed: "Update failed",
searchPlaceholder: "Search all sheets…", searchPlaceholder: "Search all sheets…",
searchEmpty: "No matches", searchEmpty: "No matches",
closeSheet: "Close this sheet?",
close: "Close",
cancel: "Cancel",
pin: "Pin",
unpin: "Unpin",
marketUpdated: "Market data updated",
exportImage: "Copy as image",
imageCopied: "Image copied",
imageFailed: "Could not copy image",
checkUpdate: "Check for updates",
upToDate: "You're on the latest version",
updateCheckFailed: "Could not check for updates",
autoUpdate: "Automatically check for updates",
dateformat: "Date format",
sysDefault: "System",
tabGeneral: "General",
tabFormat: "Format",
tabData: "Data",
tabUpdates: "Updates",
version: "Version",
}, },
ru: { ru: {
settings: "Настройки", settings: "Настройки",
@ -103,7 +82,6 @@ const dict: Record<string, Record<string, string>> = {
resultswidth: "Колонка результатов", resultswidth: "Колонка результатов",
selection: "Выделение", selection: "Выделение",
rates: "Курсы", rates: "Курсы",
market: "Рынок",
justNow: "только что", justNow: "только что",
minAgo: "{} мин назад", minAgo: "{} мин назад",
hourAgo: "{} ч назад", hourAgo: "{} ч назад",
@ -132,26 +110,6 @@ const dict: Record<string, Record<string, string>> = {
updateFailed: "Не удалось обновить", updateFailed: "Не удалось обновить",
searchPlaceholder: "Поиск по всем листам…", searchPlaceholder: "Поиск по всем листам…",
searchEmpty: "Ничего не найдено", searchEmpty: "Ничего не найдено",
closeSheet: "Закрыть этот лист?",
close: "Закрыть",
cancel: "Отмена",
pin: "Закрепить",
unpin: "Открепить",
marketUpdated: "Рыночные данные обновлены",
exportImage: "Скопировать картинкой",
imageCopied: "Картинка скопирована",
imageFailed: "Не удалось скопировать картинку",
checkUpdate: "Проверить обновления",
upToDate: "У вас последняя версия",
updateCheckFailed: "Не удалось проверить обновления",
autoUpdate: "Автоматически проверять обновления",
dateformat: "Формат даты",
sysDefault: "Системный",
tabGeneral: "Общие",
tabFormat: "Формат",
tabData: "Данные",
tabUpdates: "Обновления",
version: "Версия",
}, },
}; };

View file

@ -1,30 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { logger } from './logger';
describe('logger', () => {
beforeEach(() => {
vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should call console.info with correct arguments', () => {
logger.info('test info', { key: 'value' });
expect(console.info).toHaveBeenCalledWith('test info', { key: 'value' });
});
it('should call console.warn with correct arguments', () => {
logger.warn('test warn', 42);
expect(console.warn).toHaveBeenCalledWith('test warn', 42);
});
it('should call console.error with correct arguments', () => {
const error = new Error('test error');
logger.error('test error message', error);
expect(console.error).toHaveBeenCalledWith('test error message', error);
});
});

View file

@ -1,5 +0,0 @@
export const logger = {
info: (...args: any[]) => console.info(...args),
warn: (...args: any[]) => console.warn(...args),
error: (...args: any[]) => console.error(...args),
};

View file

@ -1,127 +0,0 @@
import { test, vi, expect } from "vitest";
vi.mock("./ui/editor", () => ({
SumEditor: vi.fn().mockImplementation(() => ({
goToLine: vi.fn(),
getSheetWithResults: vi.fn(() => "test-results"),
getText: vi.fn(() => "test"),
focus: vi.fn(),
refresh: vi.fn(),
}))
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(() => Promise.resolve(false)),
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: vi.fn(() => ({
minimize: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
})),
}));
vi.mock("./storage", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual as object,
isTauri: vi.fn(() => false),
loadSettings: vi.fn(() => ({ language: "en", autostart: false, alwaysOnTop: false, autoUpdateEnabled: false, sidebarVisible: false, hotkey: "CmdOrCtrl+Space" })),
loadAppData: vi.fn(() => ({ docs: [{id: "1", title: "Test"}], contents: {}, activeId: "1" })),
saveSettings: vi.fn(),
saveAppData: vi.fn(),
getLaunchFile: vi.fn(() => null),
flushAppData: vi.fn(),
onAppQuit: vi.fn((_cb: any) => {}),
onOpenFile: vi.fn(),
onFileDrop: vi.fn(),
registerHotkey: vi.fn(() => Promise.resolve(true)),
applyAutostart: vi.fn(),
applyAlwaysOnTop: vi.fn(),
fetchRates: vi.fn(),
fetchMarketData: vi.fn(),
loadExtensionScripts: vi.fn(() => Promise.resolve([])),
};
});
vi.mock("./ui/search", () => ({
initSearch: vi.fn(() => ({ open: vi.fn() }))
}));
vi.mock("./updater", () => ({
checkForUpdate: vi.fn(() => Promise.resolve(null))
}));
vi.mock("./extensions", () => ({
runExtensions: vi.fn(() => Promise.resolve())
}));
test("boot application", async () => {
const createMockElement = () => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
classList: { add: vi.fn(), remove: vi.fn(), toggle: vi.fn(), contains: vi.fn() },
style: { setProperty: vi.fn() },
dataset: {},
value: "",
appendChild: vi.fn(),
setAttribute: vi.fn(),
getBoundingClientRect: vi.fn(() => ({ right: 100, width: 100 })),
closest: vi.fn(),
blur: vi.fn(),
replaceChildren: vi.fn(),
});
const mock$ = vi.fn(createMockElement);
vi.stubGlobal("document", {
querySelector: mock$,
querySelectorAll: vi.fn(() => []), // For i18n
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
body: createMockElement(),
documentElement: createMockElement(),
createElement: vi.fn(createMockElement),
createElementNS: vi.fn(createMockElement),
createDocumentFragment: vi.fn(createMockElement),
head: createMockElement(),
});
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
performance: { now: () => 0 },
print: vi.fn(),
getSelection: vi.fn(() => null),
setInterval: vi.fn(),
setTimeout: vi.fn().mockImplementation((cb: Function) => cb()),
dispatchEvent: vi.fn(),
CustomEvent: vi.fn(),
});
vi.stubGlobal("matchMedia", vi.fn(() => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
matches: false,
})));
vi.stubGlobal("navigator", {
clipboard: { writeText: vi.fn() },
language: "en",
});
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
});
// Import main and wait for boot to complete
await import("./main");
await new Promise(r => setTimeout(r, 0));
// As mock is hoisted, we can import actual mocked funcs
const { loadSettings, loadAppData } = await import("./storage");
expect(loadSettings).toHaveBeenCalled();
expect(loadAppData).toHaveBeenCalled();
});

File diff suppressed because it is too large Load diff

View file

@ -1,419 +0,0 @@
import { logger } from "./logger";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
import { invoke } from '@tauri-apps/api/core';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
describe('storage', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal('window', {});
vi.stubGlobal('localStorage', {
getItem: vi.fn(),
setItem: vi.fn(),
});
vi.spyOn(logger, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('isTauri', () => {
it('returns false when not in Tauri', () => {
expect(isTauri()).toBe(false);
});
it('returns true when in Tauri', () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
expect(isTauri()).toBe(true);
});
});
describe('loadSettings', () => {
it('loads from localStorage when not in Tauri', async () => {
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ precision: 5 }));
const settings = await loadSettings();
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.settings');
expect(settings.precision).toBe(5);
expect(settings.theme).toBe(defaultSettingsData.theme);
});
it('loads via invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 }));
const settings = await loadSettings();
expect(invoke).toHaveBeenCalledWith('load_file', { name: 'settings.json' });
expect(settings.precision).toBe(8);
expect(settings.theme).toBe(defaultSettingsData.theme);
});
it('falls back to defaultSettingsData if localStorage throws', async () => {
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
const settings = await loadSettings();
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(settings).toEqual(defaultSettingsData);
});
it('falls back to defaultSettingsData if invoke throws', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
const settings = await loadSettings();
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(settings).toEqual(defaultSettingsData);
});
it('falls back to defaultSettingsData if localStorage returns null', async () => {
vi.mocked(localStorage.getItem).mockReturnValue(null);
const settings = await loadSettings();
expect(settings).toEqual(defaultSettingsData);
});
it('falls back to defaultSettingsData if JSON is invalid', async () => {
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
const settings = await loadSettings();
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(settings).toEqual(defaultSettingsData);
});
});
describe('saveSettings', () => {
it('saves to localStorage when not in Tauri', async () => {
const settings = { ...defaultSettingsData, precision: 10 };
await saveSettings(settings);
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.settings', JSON.stringify(settings, null, 2));
});
it('saves via invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
const settings = { ...defaultSettingsData, precision: 12 };
await saveSettings(settings);
expect(invoke).toHaveBeenCalledWith('save_file', { name: 'settings.json', contents: JSON.stringify(settings, null, 2) });
});
});
describe('loadAppData', () => {
const mockAppData = { docs: [], activeId: '1', contents: {} };
it('loads from localStorage when not in Tauri', async () => {
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockAppData));
const data = await loadAppData('');
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.documents');
expect(data).toEqual(mockAppData);
});
it('loads via invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
const data = await loadAppData('');
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: null });
expect(data).toEqual(mockAppData);
});
it('loads via invoke when in Tauri with specific dataDir', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
const data = await loadAppData('custom_dir');
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: 'custom_dir' });
expect(data).toEqual(mockAppData);
});
it('returns null if localStorage throws', async () => {
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
const data = await loadAppData('');
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
expect(data).toBeNull();
});
it('returns null if invoke throws', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
const data = await loadAppData('');
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
expect(data).toBeNull();
});
it('returns null if localStorage returns null', async () => {
vi.mocked(localStorage.getItem).mockReturnValue(null);
const data = await loadAppData('');
expect(data).toBeNull();
});
it('returns null if JSON is invalid', async () => {
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
const data = await loadAppData('');
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
expect(data).toBeNull();
});
});
describe('saveAppData & flushAppData', () => {
const mockAppData = { docs: [], activeId: '1', contents: {} };
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('debounces saves and flushes correctly when not in Tauri', async () => {
setDataDir('');
saveAppData(mockAppData);
// Should not save immediately
expect(localStorage.setItem).not.toHaveBeenCalled();
// Fast forward past debounce time
vi.advanceTimersByTime(400);
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
});
it('debounces saves and flushes correctly when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
setDataDir('custom_dir');
saveAppData(mockAppData);
// Should not save immediately
expect(invoke).not.toHaveBeenCalled();
// Fast forward past debounce time
vi.runAllTimers();
// storage.ts's invoke wrapper does a dynamic import() before calling
// invoke(), which resolves over real event-loop ticks (module
// transform/IO) even with fake timers active — poll with real timers
// until it settles instead of guessing a fixed number of ticks.
vi.useRealTimers();
await vi.waitFor(() => {
expect(invoke).toHaveBeenCalledWith('save_documents', { dir: 'custom_dir', contents: JSON.stringify(mockAppData) });
});
});
it('flushAppData clears timer and saves immediately', async () => {
setDataDir('');
saveAppData(mockAppData);
await flushAppData();
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
// Advancing timers should not trigger another save
vi.mocked(localStorage.setItem).mockClear();
vi.advanceTimersByTime(400);
expect(localStorage.setItem).not.toHaveBeenCalled();
});
it('flushAppData does nothing if no pending data', async () => {
await flushAppData();
expect(localStorage.setItem).not.toHaveBeenCalled();
expect(invoke).not.toHaveBeenCalled();
});
it('handles localStorage throw gracefully in flushAppData', async () => {
setDataDir('');
saveAppData(mockAppData);
vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('localStorage error'); });
await flushAppData();
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
});
it('handles invoke throw gracefully in flushAppData', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
setDataDir('');
saveAppData(mockAppData);
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
await flushAppData();
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
});
});
describe('backup and utility functions', () => {
describe('runBackups', () => {
it('does nothing when not in Tauri', async () => {
await runBackups('dir', 10);
expect(invoke).not.toHaveBeenCalled();
});
it('calls invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
await runBackups('dir', 10);
expect(invoke).toHaveBeenCalledWith('run_backups', { dir: 'dir', retentionDays: 10 });
});
it('handles invoke throw gracefully', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
await runBackups('dir', 10);
expect(logger.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
});
});
describe('backupDeletedSheet', () => {
it('does nothing when not in Tauri', async () => {
await backupDeletedSheet('dir', 'title', 'contents');
expect(invoke).not.toHaveBeenCalled();
});
it('does nothing if contents are empty', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
await backupDeletedSheet('dir', 'title', ' ');
expect(invoke).not.toHaveBeenCalled();
});
it('calls invoke when in Tauri with valid contents', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
await backupDeletedSheet('dir', 'title', 'contents');
expect(invoke).toHaveBeenCalledWith('backup_deleted_sheet', { dir: 'dir', title: 'title', contents: 'contents' });
});
it('handles invoke throw gracefully', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
await backupDeletedSheet('dir', 'title', 'contents');
expect(logger.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
});
});
describe('openBackupsFolder', () => {
it('does nothing when not in Tauri', async () => {
await openBackupsFolder('dir');
expect(invoke).not.toHaveBeenCalled();
});
it('calls invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
await openBackupsFolder('dir');
expect(invoke).toHaveBeenCalledWith('open_backups_folder', { dir: 'dir' });
});
});
describe('chooseFolder', () => {
it('returns null when not in Tauri', async () => {
expect(await chooseFolder()).toBeNull();
});
it('returns path when in Tauri and folder is chosen', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockResolvedValue('chosen_path');
expect(await chooseFolder()).toBe('chosen_path');
expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined);
});
it('returns null when in Tauri and folder choice is cancelled', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockResolvedValue(null);
expect(await chooseFolder()).toBeNull();
});
});
describe('dataDirHasDocuments', () => {
it('returns false when not in Tauri', async () => {
expect(await dataDirHasDocuments('dir')).toBe(false);
});
it('calls invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockResolvedValue(true);
expect(await dataDirHasDocuments('dir')).toBe(true);
expect(invoke).toHaveBeenCalledWith('data_dir_has_documents', { dir: 'dir' });
});
});
describe('migrateDataDir', () => {
it('does nothing when not in Tauri', async () => {
await migrateDataDir('old', 'new', 'move');
expect(invoke).not.toHaveBeenCalled();
});
it('calls invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
await migrateDataDir('old', 'new', 'move');
expect(invoke).toHaveBeenCalledWith('migrate_data_dir', { oldDir: 'old', newDir: 'new', strategy: 'move' });
});
});
});
describe('fetchRates', () => {
it('uses invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
const mockRates = { date: '2024-01-01', rates: { EUR: 0.85 }, fetchedAt: 12345 };
vi.mocked(invoke).mockResolvedValue(mockRates);
const result = await fetchRates(true);
expect(invoke).toHaveBeenCalledWith('fetch_rates', { force: true });
expect(result).toEqual(mockRates);
});
it('uses fetch when not in Tauri', async () => {
const mockResponse = { result: 'success', time_last_update_utc: '2024-01-01', rates: { EUR: 0.85 } };
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
const result = await fetchRates();
expect(fetch).toHaveBeenCalledWith('https://open.er-api.com/v6/latest/USD');
expect(result?.date).toBe('2024-01-01');
expect(result?.rates).toEqual({ EUR: 0.85 });
expect(result?.fetchedAt).toBeTypeOf('number');
});
it('returns null if fetch fails', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
const result = await fetchRates();
expect(logger.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
expect(result).toBeNull();
});
it('returns null if API result is not success', async () => {
const mockResponse = { result: 'error', 'error-type': 'unsupported-code' };
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
const result = await fetchRates();
expect(result).toBeNull();
});
});
describe('fetchHistoricalRates', () => {
it('uses invoke when in Tauri', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
const mockRates = { EUR: 0.85 };
vi.mocked(invoke).mockResolvedValue(mockRates);
const result = await fetchHistoricalRates('2024-01-01');
expect(invoke).toHaveBeenCalledWith('fetch_historical_rates', { date: '2024-01-01' });
expect(result).toEqual(mockRates);
});
it('uses fetch when not in Tauri and formats rates correctly', async () => {
const mockResponse = { rates: { EUR: 0.85, GBP: 0.75, JPY: -1 } }; // negative value should be ignored
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
const result = await fetchHistoricalRates('2024-01-01');
expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2024-01-01?from=USD');
expect(result).toEqual({ USD: 1, EUR: 0.85, GBP: 0.75 }); // USD: 1 added, negative ignored
});
it('returns null if fetch fails', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
const result = await fetchHistoricalRates('2024-01-01');
expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
expect(result).toBeNull();
});
it('returns null if rates missing in response', async () => {
const mockResponse = { amount: 1, base: 'USD', date: '2024-01-01' }; // missing rates
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
const result = await fetchHistoricalRates('2024-01-01');
expect(result).toBeNull();
});
});
});

View file

@ -1,4 +1,3 @@
import { logger } from "./logger";
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`. // Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
export interface DocMeta { export interface DocMeta {
@ -6,7 +5,6 @@ export interface DocMeta {
title: string; title: string;
/** user renamed the document — stop deriving the title from the first line */ /** user renamed the document — stop deriving the title from the first line */
customTitle?: boolean; customTitle?: boolean;
pinned?: boolean;
} }
export interface AppData { export interface AppData {
@ -33,10 +31,6 @@ export interface SettingsData {
deletedRetentionDays: number; deletedRetentionDays: number;
/** keep the window above others */ /** keep the window above others */
alwaysOnTop: boolean; alwaysOnTop: boolean;
/** date display layout; "system" also picks day/month order for ambiguous input like 01/02/2024 */
dateFormat: "system" | "iso" | "dmy" | "mdy";
/** background update checks at boot + every few hours; manual "Check for updates" always works regardless */
autoUpdateEnabled: boolean;
} }
export const defaultSettingsData: SettingsData = { export const defaultSettingsData: SettingsData = {
@ -53,8 +47,6 @@ export const defaultSettingsData: SettingsData = {
dataDir: "", dataDir: "",
deletedRetentionDays: 14, deletedRetentionDays: 14,
alwaysOnTop: false, alwaysOnTop: false,
dateFormat: "system",
autoUpdateEnabled: true,
}; };
/** "" -> undefined for the Rust side (default app data dir) */ /** "" -> undefined for the Rust side (default app data dir) */
@ -80,7 +72,7 @@ export async function loadSettings(): Promise<SettingsData> {
: localStorage.getItem("summarum.settings"); : localStorage.getItem("summarum.settings");
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) }; if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
} catch (e) { } catch (e) {
logger.warn("loadSettings failed", e); console.warn("loadSettings failed", e);
} }
return { ...defaultSettingsData }; return { ...defaultSettingsData };
} }
@ -100,7 +92,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
: localStorage.getItem("summarum.documents"); : localStorage.getItem("summarum.documents");
if (raw) return JSON.parse(raw); if (raw) return JSON.parse(raw);
} catch (e) { } catch (e) {
logger.warn("loadAppData failed", e); console.warn("loadAppData failed", e);
} }
return null; return null;
} }
@ -133,7 +125,7 @@ export async function flushAppData(): Promise<void> {
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw }); if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
else localStorage.setItem("summarum.documents", raw); else localStorage.setItem("summarum.documents", raw);
} catch (e) { } catch (e) {
logger.warn("saveAppData failed", e); console.warn("saveAppData failed", e);
} }
} }
@ -151,7 +143,7 @@ export async function runBackups(dataDir: string, retentionDays: number): Promis
try { try {
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays }); await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
} catch (e) { } catch (e) {
logger.warn("runBackups failed", e); console.warn("runBackups failed", e);
} }
} }
@ -160,7 +152,7 @@ export async function backupDeletedSheet(dataDir: string, title: string, content
try { try {
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents }); await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
} catch (e) { } catch (e) {
logger.warn("backupDeletedSheet failed", e); console.warn("backupDeletedSheet failed", e);
} }
} }
@ -168,10 +160,11 @@ export async function openBackupsFolder(dataDir: string): Promise<void> {
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) }); if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
} }
/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */
export async function chooseFolder(): Promise<string | null> { export async function chooseFolder(): Promise<string | null> {
if (!isTauri()) return null; if (!isTauri()) return null;
return (await invoke<string | null>("pick_data_dir")) ?? 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> { export async function dataDirHasDocuments(dir: string): Promise<boolean> {
@ -206,71 +199,11 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) }; return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
} }
} catch (e) { } catch (e) {
logger.warn("fetchRates failed", e); console.warn("fetchRates failed", e);
} }
return null; return null;
} }
// ---------- historical rates
export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record<string, Record<string, number>>> {
if (dates.length === 0) return {};
try {
if (isTauri()) {
return await invoke<Record<string, Record<string, number>>>("fetch_historical_rates_batch", { dates });
}
const results: Record<string, Record<string, number>> = {};
await Promise.all(
dates.map(async (date) => {
const rates = await fetchHistoricalRates(date);
if (rates) results[date] = rates;
})
);
return results;
} catch (e) {
logger.warn("fetchHistoricalRatesBatch failed", e);
return {};
}
}
export async function fetchHistoricalRates(date: string): Promise<Record<string, number> | null> {
try {
if (isTauri()) {
return await invoke<Record<string, number>>("fetch_historical_rates", { date });
}
const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`);
const json = await res.json();
if (!json?.rates) return null;
const out: Record<string, number> = { USD: 1 };
for (const [k, v] of Object.entries(json.rates as Record<string, number>)) {
if (v > 0) out[k.toUpperCase()] = v;
}
return out;
} catch (e) {
logger.warn("fetchHistoricalRates failed", date, e);
return null;
}
}
// ---------- image export
export async function writeImageFile(dataBase64: string): Promise<boolean> {
if (!isTauri()) return false;
return invoke("write_image_file", { dataBase64 });
}
// ---------- market data
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {
if (!isTauri() || symbols.length === 0) return {};
try {
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
} catch (e) {
logger.warn("fetchMarketData failed", e);
return {};
}
}
// ---------- extensions // ---------- extensions
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> { export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
@ -278,7 +211,7 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri
try { try {
return await invoke<{ name: string; code: string }[]>("load_extensions"); return await invoke<{ name: string; code: string }[]>("load_extensions");
} catch (e) { } catch (e) {
logger.warn("loadExtensions failed", e); console.warn("loadExtensions failed", e);
return []; return [];
} }
} }
@ -315,7 +248,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
const content = await invoke<string>("read_text_file", { path }); const content = await invoke<string>("read_text_file", { path });
cb(content); cb(content);
} catch (e) { } catch (e) {
logger.warn("file drop rejected", path, e); console.warn("file drop rejected", path, e);
} }
} }
}); });

View file

@ -21,7 +21,6 @@
--border: #e4e4e0; --border: #e4e4e0;
--sel: #cfe3f7; --sel: #cfe3f7;
--caret: #d57d2c; --caret: #d57d2c;
--error: #c1443a;
--toolbar-bg: #fafaf8; --toolbar-bg: #fafaf8;
--hover: #ececea; --hover: #ececea;
} }
@ -42,33 +41,11 @@
--border: #333438; --border: #333438;
--sel: #2d4a68; --sel: #2d4a68;
--caret: #e8923c; --caret: #e8923c;
--error: #e2685c;
--toolbar-bg: #232428; --toolbar-bg: #232428;
--hover: #2f3035; --hover: #2f3035;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* app-wide: buttons/list items/status text ease their hover and state
changes instead of snapping; high-frequency UI (results overlay,
autocomplete, drag handles) is deliberately excluded below */
button, .doc-item, .search-item, #rates-info, #total-line {
transition: background-color 0.15s ease, color 0.15s ease;
}
/* thin scrollbars everywhere (WebView2 is Chromium — ::-webkit-* applies) */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--fg-muted); }
/* keyboard-only focus ring (mouse clicks don't trigger :focus-visible) on the
form fields that otherwise zero out — or never set — a visible outline */
#doc-title:focus-visible,
#search-input:focus-visible,
#settings-panel input:focus-visible,
#settings-panel select:focus-visible,
.path-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
html, body { margin: 0; height: 100%; } html, body { margin: 0; height: 100%; }
body { body {
font-family: var(--font); font-family: var(--font);
@ -89,20 +66,13 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 6px; padding: 6px;
overflow: hidden;
transition: width 0.18s ease, min-width 0.18s ease, padding 0.18s ease, border-right-color 0.18s ease;
}
#sidebar.hidden {
width: 0;
min-width: 0;
padding: 0;
border-right-color: transparent;
} }
#sidebar.hidden { display: none; }
#doc-list { flex: 1; overflow-y: auto; } #doc-list { flex: 1; overflow-y: auto; }
.doc-item { .doc-item {
padding: 7px 10px; padding: 7px 10px;
border-radius: 6px; border-radius: 6px;
cursor: grab; cursor: pointer;
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -110,40 +80,26 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
border-top: 2px solid transparent;
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease, border-top-color 0.15s ease;
} }
.doc-item:hover { background: var(--hover); } .doc-item:hover { background: var(--hover); }
.doc-item.active { background: var(--sel); } .doc-item.active { background: var(--sel); }
.doc-item.dragging { opacity: 0.4; }
.doc-item.drag-over { border-top-color: var(--accent); }
.doc-name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.doc-item .pin-btn,
.doc-item .del { .doc-item .del {
flex-shrink: 0; margin-left: auto;
opacity: 0; opacity: 0;
color: var(--fg-muted); color: var(--fg-muted);
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
font-size: 11px; font-size: 12px;
border-radius: 4px; border-radius: 4px;
padding: 2px 4px; padding: 2px 6px;
line-height: 1;
transition: opacity 0.15s ease, background-color 0.15s ease;
} }
.doc-item:hover .pin-btn,
.doc-item:hover .del { opacity: 0.7; }
.doc-item .pin-btn.active { opacity: 1; }
.doc-item:hover .pin-btn.active { opacity: 1; }
.doc-item .del { font-size: 12px; padding: 2px 6px; }
.doc-item:hover .del { opacity: 1; } .doc-item:hover .del { opacity: 1; }
.doc-item .del.confirm { .doc-item .del.confirm {
opacity: 1; opacity: 1;
background: #d63a3a; background: #d63a3a;
color: #fff; color: #fff;
} }
.doc-item.pinned .doc-name { font-weight: 500; }
#new-doc { #new-doc {
border: 1px dashed var(--border); border: 1px dashed var(--border);
background: none; background: none;
@ -215,18 +171,14 @@ body {
flex-shrink: 0; flex-shrink: 0;
gap: 8px; gap: 8px;
} }
#rates-info, #market-info { #rates-info {
font-size: 11.5px; font-size: 11.5px;
color: var(--fg-muted); color: var(--fg-muted);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
#rates-info:hover, #market-info:hover { color: var(--fg); } #rates-info:hover { color: var(--fg); }
#rates-info.spin, #market-info.spin { pointer-events: none; animation: rates-pulse 1s ease-in-out infinite; } #rates-info.spin { opacity: 0.5; pointer-events: none; }
@keyframes rates-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.95; }
}
#total-line { #total-line {
font-family: var(--mono); font-family: var(--mono);
font-size: 13px; font-size: 13px;
@ -259,18 +211,8 @@ body {
min-width: 160px; min-width: 160px;
padding: 4px 0; padding: 4px 0;
z-index: 100; z-index: 100;
transform-origin: bottom right;
opacity: 0;
visibility: hidden;
transform: scale(0.97) translateY(4px);
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0s linear 0.12s;
}
#export-menu:not(.hidden) {
opacity: 1;
visibility: visible;
transform: scale(1) translateY(0);
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0s linear 0s;
} }
#export-menu.hidden { display: none; }
#export-menu button { #export-menu button {
display: block; display: block;
width: 100%; width: 100%;
@ -379,16 +321,6 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.result-line:hover { text-decoration: underline; } .result-line:hover { text-decoration: underline; }
.result-line.result-error {
color: var(--error);
font-style: italic;
}
.result-sparkline {
position: absolute;
right: 14px;
pointer-events: none;
opacity: 0.85;
}
/* ---------- settings */ /* ---------- settings */
#settings-panel { #settings-panel {
@ -402,58 +334,9 @@ body {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
padding: 14px; padding: 14px;
z-index: 50; z-index: 50;
transform-origin: top right;
opacity: 0;
visibility: hidden;
transform: scale(0.97) translateY(-4px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0.15s;
}
#settings-panel:not(.hidden) {
opacity: 1;
visibility: visible;
transform: scale(1) translateY(0);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0s;
} }
#settings-panel.hidden { display: none; }
#settings-panel h2 { margin: 0 0 10px; font-size: 14px; } #settings-panel h2 { margin: 0 0 10px; font-size: 14px; }
#settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
#settings-tabs .tab {
border: none;
background: none;
color: var(--fg-muted);
font-family: var(--font);
font-size: 12px;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
#settings-tabs .tab:hover { background: var(--hover); color: var(--fg); }
#settings-tabs .tab.active { background: var(--sel); color: var(--fg); }
.settings-tabpanel.hidden { display: none; }
.settings-tabpanel:not(.hidden) { animation: settings-fade-in 0.12s ease; }
@keyframes settings-fade-in {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
.settings-tabpanel-buttons { display: flex; gap: 6px; margin-top: 10px; }
.settings-tabpanel-buttons 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-tabpanel-buttons button:hover { background: var(--hover); }
.settings-version { color: var(--fg-muted); font-size: 11.5px; margin-top: 10px; }
#settings-panel label { #settings-panel label {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -472,37 +355,6 @@ body {
font-size: 12.5px; font-size: 12.5px;
width: 110px; width: 110px;
} }
#settings-panel input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 110px;
height: 4px;
border-radius: 2px;
background: transparent;
cursor: pointer;
}
#settings-panel input[type="range"]::-webkit-slider-runnable-track {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--border);
}
#settings-panel input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent);
cursor: grab;
transition: transform 0.1s ease;
}
#settings-panel input[type="range"]:active::-webkit-slider-thumb {
cursor: grabbing;
transform: scale(1.15);
}
.path-btn { .path-btn {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
@ -519,7 +371,7 @@ body {
} }
.path-btn:hover { background: var(--hover); } .path-btn:hover { background: var(--hover); }
.settings-footer { display: flex; justify-content: flex-end; gap: 6px; margin-top: 12px; } .settings-footer { display: flex; justify-content: space-between; gap: 6px; margin-top: 12px; }
.settings-footer button { .settings-footer button {
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg); background: var(--bg);
@ -557,15 +409,8 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 200;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.15s;
}
#modal:not(.hidden) {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s linear 0s;
} }
#modal.hidden { display: none; }
.modal-box { .modal-box {
background: var(--bg-soft); background: var(--bg-soft);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -573,10 +418,7 @@ body {
padding: 16px; padding: 16px;
max-width: 320px; max-width: 320px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
transform: scale(0.96) translateY(-6px);
transition: transform 0.15s ease;
} }
#modal:not(.hidden) .modal-box { transform: scale(1) translateY(0); }
.modal-box p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; } .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 { display: flex; gap: 8px; justify-content: flex-end; }
.modal-btns button { .modal-btns button {
@ -601,29 +443,19 @@ body {
justify-content: center; justify-content: center;
padding-top: 12%; padding-top: 12%;
z-index: 200; z-index: 200;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0s linear 0.15s;
}
#search-overlay:not(.hidden) {
opacity: 1;
visibility: visible;
transition: opacity 0.15s ease, visibility 0s linear 0s;
} }
#search-overlay.hidden { display: none; }
.search-box { .search-box {
background: var(--bg-soft); background: var(--bg-soft);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
transform: scale(0.96) translateY(-6px);
transition: transform 0.15s ease;
width: min(440px, 90vw); width: min(440px, 90vw);
max-height: 60vh; max-height: 60vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
#search-overlay:not(.hidden) .search-box { transform: scale(1) translateY(0); }
#search-input { #search-input {
border: none; border: none;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@ -654,4 +486,3 @@ body {
} }
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); } .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); } .cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
.d-none { display: none !important; }

View file

@ -1,11 +1,10 @@
// CodeMirror editor wired to the engine: evaluates on change, highlights // CodeMirror editor wired to the engine: evaluates on change, highlights
// engine tokens, renders the results overlay, autocompletes phrases. // engine tokens, renders the results overlay, autocompletes phrases.
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view"; import { EditorView, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state"; import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete"; import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
import { SumEngine, LineResult } from "../engine"; import { SumEngine, LineResult } from "../engine";
import { buildSparkline } from "./sparkline";
export interface EditorCallbacks { export interface EditorCallbacks {
onChange(text: string): void; onChange(text: string): void;
@ -41,7 +40,7 @@ function buildDecorations(state: EditorState): DecorationSet {
let cls: string | null = null; let cls: string | null = null;
switch (tk.t) { switch (tk.t) {
case "num": case "const": case "scale": cls = "tok-number"; break; case "num": case "const": case "scale": cls = "tok-number"; break;
case "unit": case "currency": case "repr": case "date": case "datelit": cls = "tok-unit"; 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 "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
case "func": case "agg": cls = "tok-var"; break; case "func": case "agg": cls = "tok-var"; break;
default: cls = null; default: cls = null;
@ -66,17 +65,12 @@ function buildDecorations(state: EditorState): DecorationSet {
const highlightField = StateField.define<DecorationSet>({ const highlightField = StateField.define<DecorationSet>({
create: buildDecorations, create: buildDecorations,
update(value, tr) { update(_value, tr) {
const hasResults = tr.effects.some((e) => e.is(setResults)); return buildDecorations(tr.state);
if (hasResults) return buildDecorations(tr.state);
if (tr.docChanged) return value.map(tr.changes);
return value;
}, },
provide: (f) => EditorView.decorations.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
const varHighlightReCache = new Map<string, { assignment: RegExp; highlight: RegExp }>();
/** Highlights every occurrence of the variable under the cursor. */ /** Highlights every occurrence of the variable under the cursor. */
function buildVarHighlight(state: EditorState): DecorationSet { function buildVarHighlight(state: EditorState): DecorationSet {
const builder = new RangeSetBuilder<Decoration>(); const builder = new RangeSetBuilder<Decoration>();
@ -92,68 +86,24 @@ function buildVarHighlight(state: EditorState): DecorationSet {
if (a === b) return builder.finish(); if (a === b) return builder.finish();
const word = text.slice(a, b); const word = text.slice(a, b);
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish(); if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
let cachedRe = varHighlightReCache.get(word);
if (!cachedRe) {
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
cachedRe = {
assignment: new RegExp(`^\\s*${esc}\\s*=`, "mu"),
highlight: new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu"),
};
if (varHighlightReCache.size > 1000) varHighlightReCache.clear();
varHighlightReCache.set(word, cachedRe);
}
// only highlight if the word is actually assigned somewhere in the document // only highlight if the word is actually assigned somewhere in the document
if (!cachedRe.assignment.test(text)) return builder.finish(); const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
for (const m of text.matchAll(cachedRe.highlight)) { 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" })); builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
} }
return builder.finish(); return builder.finish();
} }
const setVarHighlight = StateEffect.define<DecorationSet>();
const varHighlightField = StateField.define<DecorationSet>({ const varHighlightField = StateField.define<DecorationSet>({
create: () => Decoration.none, create: buildVarHighlight,
update(value, tr) { update(_value, tr) {
for (const e of tr.effects) { return buildVarHighlight(tr.state);
if (e.is(setVarHighlight)) return e.value;
}
if (tr.docChanged) return value.map(tr.changes);
return value;
}, },
provide: (f) => EditorView.decorations.from(f), provide: (f) => EditorView.decorations.from(f),
}); });
const varHighlightPlugin = ViewPlugin.fromClass(
class {
timeout: ReturnType<typeof setTimeout> | null = null;
constructor(view: EditorView) {
this.schedule(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.schedule(update.view);
}
}
schedule(view: EditorView) {
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
const decos = buildVarHighlight(view.state);
view.dispatch({ effects: setVarHighlight.of(decos) });
}, 150);
}
destroy() {
if (this.timeout) clearTimeout(this.timeout);
}
}
);
export class SumEditor { export class SumEditor {
view: EditorView; view: EditorView;
private resultsEl: HTMLElement; private resultsEl: HTMLElement;
@ -164,13 +114,11 @@ export class SumEditor {
resultsEl: HTMLElement, resultsEl: HTMLElement,
private engine: SumEngine, private engine: SumEngine,
private cb: EditorCallbacks, private cb: EditorCallbacks,
private evaluateDoc: (text: string) => LineResult[],
initialText: string, initialText: string,
) { ) {
this.resultsEl = resultsEl; this.resultsEl = resultsEl;
let engineOptions: { label: string; type: string; detail?: string }[] | null = null; let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
const varPattern = /^\s*([\p{L}_][\p{L}\d_]*)\s*=/u;
const completionSource = (ctx: CompletionContext): CompletionResult | null => { const completionSource = (ctx: CompletionContext): CompletionResult | null => {
const word = ctx.matchBefore(/[\p{L}_]+$/u); const word = ctx.matchBefore(/[\p{L}_]+$/u);
if (!word || (word.from === word.to && !ctx.explicit)) return null; if (!word || (word.from === word.to && !ctx.explicit)) return null;
@ -183,15 +131,9 @@ export class SumEditor {
})); }));
const options = [...engineOptions]; const options = [...engineOptions];
// document variables // document variables
const iter = ctx.state.doc.iterLines(); const text = ctx.state.doc.toString();
for (let next = iter.next(); !next.done; next = iter.next()) { for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
const line = next.value; options.push({ label: m[1], type: "variable" });
if (line.includes("=")) {
const m = varPattern.exec(line);
if (m) {
options.push({ label: m[1], type: "variable" });
}
}
} }
return { from: word.from, options, validFor: /^[\p{L}_]*$/u }; return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
}; };
@ -208,7 +150,6 @@ export class SumEditor {
resultsField, resultsField,
highlightField, highlightField,
varHighlightField, varHighlightField,
varHighlightPlugin,
EditorView.lineWrapping, EditorView.lineWrapping,
drawSelection({ cursorBlinkRate: 1000 }), drawSelection({ cursorBlinkRate: 1000 }),
// CM injects its base styles at runtime after app.css, so layout // CM injects its base styles at runtime after app.css, so layout
@ -323,7 +264,7 @@ export class SumEditor {
} }
private evaluate(text: string): void { private evaluate(text: string): void {
this.results = this.evaluateDoc(text); this.results = this.engine.evaluateDocument(text);
this.view.dispatch({ effects: setResults.of(this.results) }); this.view.dispatch({ effects: setResults.of(this.results) });
this.cb.onResults(this.results); this.cb.onResults(this.results);
} }
@ -331,28 +272,22 @@ export class SumEditor {
private renderResults(): void { private renderResults(): void {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const doc = this.view.state.doc; const doc = this.view.state.doc;
// documentTop is in screen coordinates; place results relative to the overlay
const overlayTop = this.resultsEl.getBoundingClientRect().top; const overlayTop = this.resultsEl.getBoundingClientRect().top;
const docTop = this.view.documentTop; const docTop = this.view.documentTop;
for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) { for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
const r = this.results[i]; const r = this.results[i];
const isChart = r.value?.kind === "chart"; if (!r.text) continue;
const isError = !!r.error;
if (!r.text && !isChart && !isError) continue;
const line = doc.line(i + 1); const line = doc.line(i + 1);
const block = this.view.lineBlockAt(line.from); const block = this.view.lineBlockAt(line.from);
const top = docTop + block.top - overlayTop; const top = docTop + block.top - overlayTop;
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue; if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
if (isChart && r.value?.kind === "chart") { const el = document.createElement("div");
frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top)); el.className = "result-line";
} else { el.textContent = r.text;
const el = document.createElement("div"); el.dataset.value = r.text;
el.className = "result-line" + (isError ? " result-error" : ""); el.style.top = `${top}px`;
el.textContent = isError ? "#ref?" : r.text; frag.appendChild(el);
if (!isError) el.dataset.value = r.text!;
if (isError) el.title = r.error!;
el.style.top = `${top}px`;
frag.appendChild(el);
}
} }
this.resultsEl.replaceChildren(frag); this.resultsEl.replaceChildren(frag);
} }

View file

@ -1,171 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { exportSheetImage } from './export';
import { writeImageFile } from '../storage';
vi.mock('../storage', () => ({
writeImageFile: vi.fn(),
}));
describe('exportSheetImage', () => {
let mockContext2D: any;
let mockCanvas: any;
let toastMock: any;
let tMock: any;
beforeEach(() => {
toastMock = vi.fn();
tMock = vi.fn((key: string) => `trans_${key}`);
mockContext2D = {
measureText: vi.fn().mockReturnValue({ width: 10 }),
scale: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
font: '',
fillStyle: '',
};
mockCanvas = {
getContext: vi.fn().mockReturnValue(mockContext2D),
toBlob: vi.fn((cb) => cb(new Blob(['fake-image-data'], { type: 'image/png' }))),
width: 0,
height: 0,
};
vi.stubGlobal('document', {
documentElement: {},
createElement: vi.fn((tag) => {
if (tag === 'canvas') return mockCanvas;
return {};
})
});
vi.stubGlobal('window', {
devicePixelRatio: 0
});
vi.stubGlobal('getComputedStyle', vi.fn().mockReturnValue({
getPropertyValue: vi.fn((prop) => {
if (prop === '--bg') return '';
if (prop === '--fg') return '';
if (prop === '--result') return '';
if (prop === '--mono') return '';
return '';
})
}));
vi.stubGlobal('Blob', class Blob {
content: any[];
options: any;
constructor(content: any[], options: any) {
this.content = content;
this.options = options;
}
async arrayBuffer() {
return new Uint8Array([1, 2, 3]).buffer;
}
});
vi.stubGlobal('ClipboardItem', class ClipboardItem {
constructor(data: any) {
Object.assign(this, data);
}
});
vi.stubGlobal('navigator', {
clipboard: {
write: vi.fn().mockResolvedValue(undefined)
}
});
vi.stubGlobal('btoa', (str: string) => Buffer.from(str, 'binary').toString('base64'));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('should create a canvas and copy it to clipboard successfully', async () => {
await exportSheetImage({
lines: ['1 + 1'],
results: [{ text: '2' }],
fontSize: 14,
toast: toastMock,
t: tMock
});
expect(document.createElement).toHaveBeenCalledWith('canvas');
expect(mockCanvas.getContext).toHaveBeenCalledWith('2d');
expect(mockContext2D.fillText).toHaveBeenCalledWith('1 + 1', expect.any(Number), expect.any(Number));
expect(mockContext2D.fillText).toHaveBeenCalledWith('2', expect.any(Number), expect.any(Number));
expect(navigator.clipboard.write).toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith('trans_imageCopied');
});
it('should handle blob failure', async () => {
mockCanvas.toBlob = vi.fn((cb) => cb(null));
await exportSheetImage({
lines: ['test'],
results: [{ text: null }],
fontSize: 14,
toast: toastMock,
t: tMock
});
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
expect(navigator.clipboard.write).not.toHaveBeenCalled();
});
it('should fallback to writeImageFile when clipboard write fails', async () => {
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
vi.mocked(writeImageFile).mockResolvedValue(true);
await exportSheetImage({
lines: ['fallback'],
results: [{ text: 'res' }],
fontSize: 14,
toast: toastMock,
t: tMock
});
expect(navigator.clipboard.write).toHaveBeenCalled();
expect(writeImageFile).toHaveBeenCalled();
expect(writeImageFile).toHaveBeenCalledWith('AQID');
expect(toastMock).toHaveBeenCalledWith('trans_saved');
});
it('should handle writeImageFile failure in fallback', async () => {
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
vi.mocked(writeImageFile).mockRejectedValue(new Error('save fail'));
await exportSheetImage({
lines: ['fail'],
results: [{ text: 'res' }],
fontSize: 14,
toast: toastMock,
t: tMock
});
expect(writeImageFile).toHaveBeenCalled();
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
});
it('should handle writeImageFile returning false', async () => {
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
vi.mocked(writeImageFile).mockResolvedValue(false);
await exportSheetImage({
lines: ['fail'],
results: [{ text: 'res' }],
fontSize: 14,
toast: toastMock,
t: tMock
});
expect(writeImageFile).toHaveBeenCalled();
expect(toastMock).not.toHaveBeenCalledWith('trans_saved');
});
});

View file

@ -1,79 +0,0 @@
// Renders the active sheet + its results to a PNG and copies it to the
// clipboard, falling back to a Save As dialog when the Clipboard API is
// unavailable (e.g. the window lost focus).
import { writeImageFile } from "../storage";
export interface ExportImageDeps {
lines: string[];
results: { text: string | null }[];
fontSize: number;
toast(msg: string): void;
t(key: string): string;
}
export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
const { lines: rawLines, results: doc, fontSize, toast, t } = deps;
const dpr = window.devicePixelRatio || 1;
const style = getComputedStyle(document.documentElement);
const bgColor = style.getPropertyValue("--bg").trim() || "#ffffff";
const fgColor = style.getPropertyValue("--fg").trim() || "#333333";
const resultColor = style.getPropertyValue("--result").trim() || "#d57d2c";
const monoFont = style.getPropertyValue("--mono").trim() || "monospace";
const lineH = Math.round(fontSize * 1.7);
const padX = 20, padTop = 16, padBot = 16;
const canvas = document.createElement("canvas");
const ctx2 = canvas.getContext("2d")!;
ctx2.font = `${fontSize}px ${monoFont}`;
const maxWidth = rawLines.reduce((m, l) => Math.max(m, ctx2.measureText(l).width), 0) + padX * 4 + 150;
const w = Math.max(400, maxWidth);
const h = padTop + rawLines.length * lineH + padBot;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx2.scale(dpr, dpr);
ctx2.fillStyle = bgColor;
ctx2.fillRect(0, 0, w, h);
ctx2.font = `${fontSize}px ${monoFont}`;
for (let i = 0; i < rawLines.length; i++) {
const y = padTop + i * lineH + fontSize;
ctx2.fillStyle = fgColor;
ctx2.fillText(rawLines[i], padX, y);
const res = doc[i]?.text;
if (res) {
ctx2.fillStyle = resultColor;
const rx = w - padX - ctx2.measureText(res).width;
ctx2.fillText(res, rx, y);
}
}
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/png"));
if (!blob) { toast(t("imageFailed")); return; }
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
toast(t("imageCopied"));
} catch {
// clipboard API failed — save to file
try {
const buf = await blob.arrayBuffer();
const b64 = bytesToBase64(new Uint8Array(buf));
if (await writeImageFile(b64)) {
toast(t("saved"));
}
} catch {
toast(t("imageFailed"));
}
}
}
/** chunked to avoid "Maximum call stack size exceeded" from spreading a large array as args */
function bytesToBase64(bytes: Uint8Array): string {
let bin = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}

View file

@ -1,243 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { SumEngine } from "../engine";
import { parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search";
import { Workspace } from "../workspace";
import Decimal from "decimal.js";
describe("parseResultQuery", () => {
const engine = new SumEngine();
it("parses '>=' operator correctly", () => {
const res = parseResultQuery(engine, ">= 100");
expect(res).not.toBeNull();
expect(res?.op).toBe(">=");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(100))).toBe(true);
}
});
it("parses '<=' operator correctly", () => {
const res = parseResultQuery(engine, "<= 50");
expect(res).not.toBeNull();
expect(res?.op).toBe("<=");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(50))).toBe(true);
}
});
it("parses '>' operator correctly", () => {
const res = parseResultQuery(engine, "> 0");
expect(res).not.toBeNull();
expect(res?.op).toBe(">");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(0))).toBe(true);
}
});
it("parses '<' operator correctly", () => {
const res = parseResultQuery(engine, "< -10");
expect(res).not.toBeNull();
expect(res?.op).toBe("<");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(-10))).toBe(true);
}
});
it("parses '=' operator correctly", () => {
const res = parseResultQuery(engine, "= 42.5");
expect(res).not.toBeNull();
expect(res?.op).toBe("=");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(42.5))).toBe(true);
}
});
it("parses '~' operator correctly", () => {
const res = parseResultQuery(engine, "~ 3.14");
expect(res).not.toBeNull();
expect(res?.op).toBe("~");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(3.14))).toBe(true);
}
});
it("handles whitespace correctly", () => {
const res = parseResultQuery(engine, " >= 200 ");
expect(res).not.toBeNull();
expect(res?.op).toBe(">=");
expect(res?.threshold.kind).toBe("quantity");
if (res?.threshold.kind === "quantity") {
expect(res.threshold.value.eq(new Decimal(200))).toBe(true);
}
});
it("returns null for invalid operator", () => {
const res = parseResultQuery(engine, "!= 100");
expect(res).toBeNull();
});
it("returns null for invalid value string", () => {
// "abc" cannot be evaluated to a quantity
const res = parseResultQuery(engine, ">= abc");
expect(res).toBeNull();
});
it("returns null for empty string", () => {
const res = parseResultQuery(engine, "");
expect(res).toBeNull();
});
it("returns null when no expression matches after operator", () => {
const res = parseResultQuery(engine, ">=");
expect(res).toBeNull();
});
});
describe("searchAllSheets", () => {
it("returns empty array for empty query", () => {
const deps = {} as SearchDeps;
expect(searchAllSheets(deps, " ")).toEqual([]);
});
it("performs full-text search across multiple sheets", () => {
const docs: SearchDoc[] = [
{ id: "1", title: "Doc 1", text: "Hello world\nThis is a test" },
{ id: "2", title: "Doc 2", text: "Another test\nNothing here" },
];
const deps = {
docs: () => docs,
engine: new SumEngine(),
} as SearchDeps;
const hits = searchAllSheets(deps, "test");
expect(hits).toHaveLength(2);
expect(hits[0]).toMatchObject({ docId: "1", docTitle: "Doc 1", line: 2, text: "This is a test" });
expect(hits[1]).toMatchObject({ docId: "2", docTitle: "Doc 2", line: 1, text: "Another test" });
});
it("limits results to 200 hits for text search", () => {
const lines = Array(250).fill("test string");
const docs: SearchDoc[] = [
{ id: "1", title: "Doc 1", text: lines.join("\n") },
];
const deps = {
docs: () => docs,
engine: new SumEngine(),
} as SearchDeps;
const hits = searchAllSheets(deps, "test");
expect(hits).toHaveLength(200);
});
describe("numeric threshold search", () => {
const engine = new SumEngine();
const createMockWorkspace = (results: any[]) => {
return {
getCachedResults: vi.fn().mockReturnValue(results)
} as unknown as Workspace;
};
it("matches exact value (=)", () => {
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100" }];
const workspace = createMockWorkspace([{
value: { kind: "quantity", value: new Decimal(100) },
text: "100"
}]);
const deps = {
docs: () => docs,
engine,
workspace,
} as unknown as SearchDeps;
const hits = searchAllSheets(deps, "= 100");
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ docId: "1", line: 1, result: "100" });
const noHits = searchAllSheets(deps, "= 99");
expect(noHits).toHaveLength(0);
});
it("matches greater than (>)", () => {
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "150\n50" }];
const workspace = createMockWorkspace([
{ value: { kind: "quantity", value: new Decimal(150) }, text: "150" },
{ value: { kind: "quantity", value: new Decimal(50) }, text: "50" }
]);
const deps = {
docs: () => docs,
engine,
workspace,
} as unknown as SearchDeps;
const hits = searchAllSheets(deps, "> 100");
expect(hits).toHaveLength(1);
expect(hits[0].line).toBe(1);
});
it("matches approximate value (~)", () => {
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100.5\n99.5\n105" }];
const workspace = createMockWorkspace([
{ value: { kind: "quantity", value: new Decimal(100.5) }, text: "100.5" },
{ value: { kind: "quantity", value: new Decimal(99.5) }, text: "99.5" },
{ value: { kind: "quantity", value: new Decimal(105) }, text: "105" }
]);
const deps = {
docs: () => docs,
engine,
workspace,
} as unknown as SearchDeps;
const hits = searchAllSheets(deps, "~ 100");
expect(hits).toHaveLength(2); // 100.5 and 99.5 are within 1% of 100
expect(hits[0].line).toBe(1);
expect(hits[1].line).toBe(2);
});
it("ignores non-quantity results during numeric search", () => {
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100\nnot a number" }];
const workspace = createMockWorkspace([
{ value: { kind: "quantity", value: new Decimal(100) }, text: "100" },
{ value: null, text: null }
]);
const deps = {
docs: () => docs,
engine,
workspace,
} as unknown as SearchDeps;
const hits = searchAllSheets(deps, "> 50");
expect(hits).toHaveLength(1);
});
it("limits results to 200 hits for numeric search", () => {
const lines = Array(250).fill("100");
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: lines.join("\n") }];
const results = Array(250).fill({
value: { kind: "quantity", value: new Decimal(100) },
text: "100"
});
const workspace = createMockWorkspace(results);
const deps = {
docs: () => docs,
engine,
workspace,
} as unknown as SearchDeps;
const hits = searchAllSheets(deps, "> 50");
expect(hits).toHaveLength(200);
});
});
});

View file

@ -1,184 +0,0 @@
// Full-text and numeric ("> 100", "~ 50") search across every open sheet.
// Owns the #search-overlay DOM and its own input debounce.
import { SumEngine, Value } from "../engine";
import { Workspace } from "../workspace";
export interface SearchHit {
docId: string;
docTitle: string;
line: number;
text: string;
result?: string;
}
export interface SearchDoc {
id: string;
title: string;
text: string;
}
export interface SearchDeps {
engine: SumEngine;
workspace: Workspace;
docs(): SearchDoc[];
t(key: string): string;
/** switch to docId (if not already active) and put the caret on line (1-based) */
onOpen(docId: string, line: number): void;
}
export interface SearchController {
open(): void;
}
const $ = <T extends HTMLElement>(sel: string): T =>
document.querySelector(sel) as T;
export function parseResultQuery(
engine: SumEngine,
q: string,
): { op: string; threshold: Value } | null {
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
if (!m) return null;
const v = engine.evaluateExpression(m[2].trim());
if (!v || v.kind !== "quantity") return null;
return { op: m[1], threshold: v };
}
export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const q = query.trim();
if (!q) return [];
const docs = deps.docs();
const rq = parseResultQuery(deps.engine, q);
if (rq && rq.threshold.kind === "quantity") {
const th = rq.threshold.value;
const hits: SearchHit[] = [];
for (const doc of docs) {
const results = deps.workspace.getCachedResults(doc);
let lines: string[] | undefined;
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (!r.value || r.value.kind !== "quantity") continue;
const v = r.value.value;
let match = false;
switch (rq.op) {
case ">":
match = v.gt(th);
break;
case ">=":
match = v.gte(th);
break;
case "<":
match = v.lt(th);
break;
case "<=":
match = v.lte(th);
break;
case "=":
match = v.eq(th);
break;
case "~":
match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01);
break;
}
if (match) {
if (!lines) lines = doc.text.split("\n");
hits.push({
docId: doc.id,
docTitle: doc.title,
line: i + 1,
text: lines[i] ?? "",
result: r.text ?? undefined,
});
if (hits.length >= 200) return hits;
}
}
}
return hits;
}
const ql = q.toLowerCase();
const hits: SearchHit[] = [];
for (const doc of docs) {
const lines = doc.text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(ql)) {
hits.push({
docId: doc.id,
docTitle: doc.title,
line: i + 1,
text: lines[i],
});
if (hits.length >= 200) return hits;
}
}
}
return hits;
}
export function initSearch(deps: SearchDeps): SearchController {
const overlay = $("#search-overlay");
const input = $<HTMLInputElement>("#search-input");
const resultsEl = $("#search-results");
function close(): void {
overlay.classList.add("hidden");
}
function render(query: string, hits: SearchHit[]): void {
resultsEl.replaceChildren();
if (query.trim() && hits.length === 0) {
const empty = document.createElement("div");
empty.className = "search-empty";
empty.textContent = deps.t("searchEmpty");
resultsEl.appendChild(empty);
return;
}
const frag = document.createDocumentFragment();
for (const hit of hits) {
const item = document.createElement("div");
item.className = "search-item";
const docEl = document.createElement("div");
docEl.className = "doc";
docEl.textContent = hit.docTitle;
const lineEl = document.createElement("div");
lineEl.className = "line";
lineEl.textContent = hit.result
? `${hit.text} = ${hit.result}`
: hit.text;
item.append(docEl, lineEl);
item.addEventListener("click", () => {
deps.onOpen(hit.docId, hit.line);
close();
});
frag.appendChild(item);
}
resultsEl.appendChild(frag);
}
// a numeric query re-evaluates every open sheet — debounce so fast typing
// doesn't recompute the whole workspace on every keystroke
let searchTimer: ReturnType<typeof setTimeout> | null = null;
input.addEventListener("input", () => {
if (searchTimer) clearTimeout(searchTimer);
const q = input.value;
searchTimer = setTimeout(() => render(q, searchAllSheets(deps, q)), 150);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
close();
} else if (e.key === "Enter") {
resultsEl.querySelector<HTMLElement>(".search-item")?.click();
}
});
overlay.addEventListener("mousedown", (e) => {
if (e.target === overlay) close();
});
return {
open(): void {
overlay.classList.remove("hidden");
input.value = "";
render("", []);
input.focus();
},
};
}

View file

@ -1,115 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { buildSparkline } from "./sparkline";
describe("buildSparkline", () => {
beforeEach(() => {
vi.stubGlobal("document", {
createElementNS: vi.fn((ns, tag) => {
const attributes: Record<string, string> = {};
const classes: string[] = [];
const children: any[] = [];
const style: Record<string, string> = {};
return {
tagName: tag,
namespaceURI: ns,
setAttribute: vi.fn((key, val) => {
attributes[key] = val;
}),
getAttribute: vi.fn((key) => attributes[key]),
classList: {
add: vi.fn((cls) => {
classes.push(cls);
}),
contains: vi.fn((cls) => classes.includes(cls)),
},
style,
appendChild: vi.fn((child) => {
children.push(child);
}),
_getAttributes: () => attributes,
_getClasses: () => classes,
_getChildren: () => children,
};
}),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("creates a base SVG element with correct attributes and style", () => {
const pts = [10, 20, 30];
const svg = buildSparkline(pts, 150) as any;
expect(document.createElementNS).toHaveBeenCalledWith("http://www.w3.org/2000/svg", "svg");
expect(svg.tagName).toBe("svg");
expect(svg._getAttributes()).toMatchObject({
width: "80",
height: "20",
viewBox: "0 0 80 20",
});
expect(svg._getClasses()).toContain("result-sparkline");
expect(svg.style.top).toBe("150px");
});
it("appends a straight line when all points are equal (min === max)", () => {
const pts = [10, 10, 10];
const svg = buildSparkline(pts, 0) as any;
const children = svg._getChildren();
expect(children.length).toBe(1);
const line = children[0];
expect(line.tagName).toBe("line");
expect(line._getAttributes()).toMatchObject({
x1: "2",
y1: "10",
x2: "78",
y2: "10",
stroke: "var(--result)",
"stroke-width": "1.5",
});
});
it("appends a polyline and a circle for varying points", () => {
const pts = [10, 20, 30]; // min=10, max=30
const svg = buildSparkline(pts, 0) as any;
const children = svg._getChildren();
expect(children.length).toBe(2);
const polyline = children[0];
expect(polyline.tagName).toBe("polyline");
expect(polyline._getAttributes()).toMatchObject({
fill: "none",
stroke: "var(--result)",
"stroke-width": "1.5",
"stroke-linecap": "round",
"stroke-linejoin": "round",
});
// Check points calculation
const pointsStr = polyline._getAttributes().points;
expect(typeof pointsStr).toBe("string");
const points = pointsStr.split(" ");
expect(points.length).toBe(3);
// First point should be at x=2 (pad), y=18 (H-pad because value is min)
expect(points[0]).toBe("2.0,18.0");
// Middle point should be at x=40 (pad + W/2), y=10 (pad + H/2 because value is mid)
expect(points[1]).toBe("40.0,10.0");
// Last point should be at x=78 (W-pad), y=2 (pad because value is max)
expect(points[2]).toBe("78.0,2.0");
const circle = children[1];
expect(circle.tagName).toBe("circle");
expect(circle._getAttributes()).toMatchObject({
cx: "78.0",
cy: "2.0",
r: "2",
fill: "var(--result)",
});
});
});

View file

@ -1,45 +0,0 @@
export function buildSparkline(pts: number[], top: number): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const W = 80, H = 20, pad = 2;
const svg = document.createElementNS(NS, "svg") as SVGSVGElement;
svg.setAttribute("width", String(W));
svg.setAttribute("height", String(H));
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.classList.add("result-sparkline");
svg.style.top = `${top}px`;
const min = Math.min(...pts), max = Math.max(...pts);
if (min === max) {
const line = document.createElementNS(NS, "line");
line.setAttribute("x1", String(pad));
line.setAttribute("y1", String(H / 2));
line.setAttribute("x2", String(W - pad));
line.setAttribute("y2", String(H / 2));
line.setAttribute("stroke", "var(--result)");
line.setAttribute("stroke-width", "1.5");
svg.appendChild(line);
} else {
const coords = pts.map((v, i) => {
const x = pad + (i / (pts.length - 1)) * (W - 2 * pad);
const y = pad + (1 - (v - min) / (max - min)) * (H - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const poly = document.createElementNS(NS, "polyline");
poly.setAttribute("points", coords);
poly.setAttribute("fill", "none");
poly.setAttribute("stroke", "var(--result)");
poly.setAttribute("stroke-width", "1.5");
poly.setAttribute("stroke-linecap", "round");
poly.setAttribute("stroke-linejoin", "round");
svg.appendChild(poly);
const last = pts[pts.length - 1];
const cx = (W - pad).toFixed(1);
const cy = (pad + (1 - (last - min) / (max - min)) * (H - 2 * pad)).toFixed(1);
const dot = document.createElementNS(NS, "circle");
dot.setAttribute("cx", cx);
dot.setAttribute("cy", cy);
dot.setAttribute("r", "2");
dot.setAttribute("fill", "var(--result)");
svg.appendChild(dot);
}
return svg;
}

View file

@ -1,81 +0,0 @@
import { logger } from "./logger";
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { checkForUpdate } from './updater';
import * as storage from './storage';
import type { Update } from '@tauri-apps/plugin-updater';
vi.mock('./storage', () => ({
isTauri: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-updater', () => ({
check: vi.fn(),
}));
vi.mock('@tauri-apps/api/app', () => ({
getVersion: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-process', () => ({
relaunch: vi.fn(),
}));
describe('checkForUpdate', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(logger, 'info').mockImplementation(() => {});
vi.spyOn(logger, 'warn').mockImplementation(() => {});
});
it('should return null if not in Tauri', async () => {
vi.mocked(storage.isTauri).mockReturnValue(false);
const result = await checkForUpdate();
expect(result).toBeNull();
});
it('should return "error" if dynamic import or check throws', async () => {
vi.mocked(storage.isTauri).mockReturnValue(true);
const { check } = await import('@tauri-apps/plugin-updater');
vi.mocked(check).mockRejectedValue(new Error('Network error'));
const result = await checkForUpdate();
expect(result).toBe('error');
expect(logger.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
});
it('should return null if no update is available', async () => {
vi.mocked(storage.isTauri).mockReturnValue(true);
const { check } = await import('@tauri-apps/plugin-updater');
const { getVersion } = await import('@tauri-apps/api/app');
vi.mocked(check).mockResolvedValue(null);
vi.mocked(getVersion).mockResolvedValue('1.0.0');
const result = await checkForUpdate();
expect(result).toBeNull();
expect(logger.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
});
it('should return an update object if an update is available', async () => {
vi.mocked(storage.isTauri).mockReturnValue(true);
const { check } = await import('@tauri-apps/plugin-updater');
const mockUpdate = {
version: '1.1.0',
currentVersion: '1.0.0',
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
} as unknown as Update;
vi.mocked(check).mockResolvedValue(mockUpdate);
const result = await checkForUpdate();
expect(result).not.toBeNull();
if (result !== 'error' && result !== null) {
expect(result.version).toBe('1.1.0');
const { relaunch } = await import('@tauri-apps/plugin-process');
await result.install();
expect(mockUpdate.downloadAndInstall).toHaveBeenCalled();
expect(relaunch).toHaveBeenCalled();
}
});
});

View file

@ -1,29 +1,17 @@
import { logger } from "./logger";
import { isTauri } from "./storage"; import { isTauri } from "./storage";
interface AvailableUpdate { export interface AvailableUpdate {
version: string; version: string;
install(): Promise<void>; install(): Promise<void>;
} }
/** /** null if no update, offline, or running outside Tauri (`vite dev`). */
* null = no update available (checked successfully); "error" = the check export async function checkForUpdate(): Promise<AvailableUpdate | null> {
* itself failed (offline, updater/signature error); running outside Tauri
* (`vite dev`) also reports null.
*/
export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"> {
if (!isTauri()) return null; if (!isTauri()) return null;
try { try {
const { check } = await import("@tauri-apps/plugin-updater"); const { check } = await import("@tauri-apps/plugin-updater");
const update = await check(); const update = await check();
if (!update) { if (!update) return null;
// visible in devtools console — the fastest way to tell "already on
// the latest published release" apart from "the check silently failed"
const { getVersion } = await import("@tauri-apps/api/app");
logger.info(`no update: running ${await getVersion()}, that's the latest published release`);
return null;
}
logger.info(`update available: ${update.currentVersion} -> ${update.version}`);
return { return {
version: update.version, version: update.version,
async install() { async install() {
@ -32,8 +20,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
await relaunch(); await relaunch();
}, },
}; };
} catch (e) { } catch {
logger.warn("update check failed", e); return null;
return "error";
} }
} }

View file

@ -1,163 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { SumEngine } from "./engine";
import { Workspace, SheetSource } from "./workspace";
function makeWorkspace(initial: Record<string, { title: string; text: string }>) {
const engine = new SumEngine();
const store = new Map(Object.entries(initial).map(([id, v]) => [id, { id, ...v }]));
const sheets = (): SheetSource[] => [...store.values()];
const ws = new Workspace(engine, sheets);
return { engine, store, ws };
}
describe("Workspace exports", () => {
it("exposes assigned variables, total and last through @Sheet.key", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500\nfood = $200" },
});
const r = ws.evaluateSheet("dash", "@Budget.rent\n@Budget.total\n@Budget.last");
expect(r[0].text).toBe("$500");
expect(r[1].text).toBe("$700");
expect(r[2].text).toBe("$200");
});
it("caches a sheet's exports until invalidated", () => {
const { ws, store } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
});
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500");
store.get("budget")!.text = "rent = $900";
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500");
ws.invalidate("budget");
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$900");
});
it("evaluates a sheet lazily — only when it's actually referenced", () => {
const { engine, ws } = makeWorkspace({
never: { title: "Never", text: "1 + 1" },
});
const spy = vi.spyOn(engine, "evaluateDocument");
ws.evaluateSheet("dash", "hello");
expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(false);
ws.evaluateSheet("dash", "@Never.total");
expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(true);
});
});
describe("Workspace error handling", () => {
it("cleans up the resolving set if evaluation throws an unexpected error", () => {
const { ws, engine } = makeWorkspace({
a: { title: "A", text: "1 + 1" },
});
vi.spyOn(engine, "evaluateDocument").mockImplementation(() => {
throw new Error("mocked crash");
});
expect(() => ws.evaluateSheet("a", "1 + 1")).toThrowError("mocked crash");
expect((ws as any).resolving.has("a")).toBe(false);
});
});
describe("Workspace invalidation", () => {
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
const { ws, store } = makeWorkspace({
a: { title: "A", text: "x = 10" },
b: { title: "B", text: "z = @A.x" },
c: { title: "C", text: "y = 5" },
});
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10");
expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5");
store.get("a")!.text = "x = 99";
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10");
ws.invalidate("a");
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("99");
expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5");
});
});
describe("Workspace cycle detection", () => {
it("a true two-sheet cycle terminates instead of looping forever", () => {
const { ws } = makeWorkspace({
a: { title: "A", text: "z = @B.total" },
b: { title: "B", text: "z = @A.total" },
});
const r = ws.evaluateSheet("dash", "@A.z");
expect(r[0].value).toBeNull();
expect(r[0].error).toBeDefined();
});
it("a sheet referencing itself surfaces a circular reference immediately", () => {
const { ws } = makeWorkspace({ self: { title: "Self", text: "" } });
const text = "a = 5\nb = @Self.a";
expect(() => ws.evaluateSheet("self", text)).not.toThrow();
const r = ws.evaluateSheet("self", text);
expect(r[1].value).toBeNull();
expect(r[1].error).toBe("circular reference");
});
it("does not cache exports for a sheet whose own line hit circular reference", () => {
// Resolving @A.z evaluates A -> resolves @B.total -> evaluates B -> resolves
// @A.total -> A is already on the resolving stack, so B's own line
// (`z = @A.total`) gets the literal error "circular reference". A's own line
// only sees the secondary error 'sheet "B" has no total', so A gets cached —
// but B must not, since its LineResult[] literally contains "circular reference".
const { engine, ws } = makeWorkspace({
a: { title: "A", text: "z = @B.total" },
b: { title: "B", text: "z = @A.total" },
});
const first = ws.evaluateSheet("dash", "@A.z");
expect(first[0].error).toBe('no variable "z" in "A"');
const spy = vi.spyOn(engine, "evaluateDocument");
const second = ws.evaluateSheet("dash2", "@B.z");
expect(second[0].error).toBe('no variable "z" in "B"');
// A is cached from the first pass, so this second call re-evaluates B only.
// If exportsFor(B) had cached the poisoned result from the first pass instead,
// B's text would never be re-evaluated here.
expect(spy.mock.calls.some(([text]) => text === "z = @A.total")).toBe(true);
});
});
describe("Workspace.renameSheet", () => {
it("rewrites bare-form references to the new title", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
dash: { title: "Dashboard", text: "@Budget.rent + 1" },
});
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([{ id: "dash", text: "@Money.rent + 1" }]);
});
it("switches to bracket form when the new title needs it", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
dash: { title: "Dashboard", text: "@Budget.rent" },
});
expect(ws.renameSheet("budget", "Budget", "Monthly Budget")).toEqual([
{ id: "dash", text: "@[Monthly Budget].rent" },
]);
});
it("rewrites existing bracket-form references too", () => {
const { ws } = makeWorkspace({
trip: { title: "Trip to Lisbon", text: "food = 25" },
dash: { title: "Dashboard", text: "@[Trip to Lisbon].food" },
});
expect(ws.renameSheet("trip", "Trip to Lisbon", "Lisbon")).toEqual([{ id: "dash", text: "@Lisbon.food" }]);
});
it("never rewrites the renamed sheet's own text", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "a = 1\nb = @Budget.a" },
});
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]);
});
it("leaves unrelated sheets untouched", () => {
const { ws } = makeWorkspace({
budget: { title: "Budget", text: "rent = $500" },
other: { title: "Other", text: "just text, no refs" },
});
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]);
});
});

View file

@ -1,174 +0,0 @@
// Owns the cross-sheet dependency graph: which sheets a `@Sheet.key`
// reference can see, cached exports, cycle detection, and rename rewrites.
// The engine has no concept of "other sheets" — that lives entirely here.
import type { SumEngine, LineResult } from "./engine";
import type { Value } from "./engine/types";
import type { XRefResolution } from "./engine/evaluator";
export interface SheetSource {
id: string;
title: string;
text: string;
}
interface SheetExports {
vars: Map<string, Value>;
total: Value | null;
last: Value | null;
results: LineResult[];
}
const XREF_SCAN_RE = /@(?:\[([^\]]+)\]|([\p{L}_][\p{L}\d_]*))\./gu;
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export class Workspace {
private cache = new Map<string, SheetExports>();
private resolving: Set<string> = new Set();
private parsedRefsCache = new Map<string, { text: string; refs: string[] }>();
private titleCache: Map<string, SheetSource> | null = null;
constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {}
private doEvaluate(sheetId: string, text: string): LineResult[] {
this.resolving.add(sheetId);
try {
return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key));
} finally {
this.resolving.delete(sheetId);
}
}
/** Evaluate a sheet as the user's active/open document — refs resolve live. */
evaluateSheet(sheetId: string, text: string): LineResult[] {
return this.doEvaluate(sheetId, text);
}
getCachedResults(sheet: SheetSource): LineResult[] {
return this.exportsFor(sheet).results;
}
/** Drop cached exports for a sheet and everything that transitively depends on it. */
invalidate(sheetId: string): void {
this.titleCache = null;
const dirty = new Set<string>([sheetId]);
const sheetsList = this.sheets();
let changed = true;
while (changed) {
changed = false;
for (const s of sheetsList) {
if (dirty.has(s.id)) continue;
for (const title of this.referencedTitles(s.text, s.id)) {
const target = this.findSheetByTitle(title);
if (target && dirty.has(target.id)) {
dirty.add(s.id);
changed = true;
break;
}
}
}
}
for (const id of dirty) this.cache.delete(id);
}
invalidateAll(): void {
this.titleCache = null;
this.cache.clear();
this.parsedRefsCache.clear();
}
/**
* Explicit rename: rewrite `@OldTitle.` / `@[Old Title].` to the new name
* in every *other* sheet. Returns only the sheets whose text changed.
*/
renameSheet(renamedId: string, oldTitle: string, newTitle: string): { id: string; text: string }[] {
const oldEsc = escapeRegExp(oldTitle.trim());
const bareRe = new RegExp(`@${oldEsc}\\.`, "gu");
const bracketRe = new RegExp(`@\\[\\s*${oldEsc}\\s*\\]\\.`, "gu");
const trimmedNew = newTitle.trim();
const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`;
const out: { id: string; text: string }[] = [];
for (const s of this.sheets()) {
if (s.id === renamedId) continue;
const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement);
if (rewritten !== s.text) out.push({ id: s.id, text: rewritten });
}
return out;
}
private referencedTitles(text: string, id?: string): string[] {
if (id) {
const cached = this.parsedRefsCache.get(id);
if (cached && cached.text === text) return cached.refs;
}
const out: string[] = [];
for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim());
if (id) {
this.parsedRefsCache.set(id, { text, refs: out });
}
return out;
}
private findSheetByTitle(title: string): SheetSource | undefined {
const needle = title.trim().toLowerCase();
if (this.titleCache === null) {
this.titleCache = new Map();
for (const s of this.sheets()) {
const key = s.title.trim().toLowerCase();
if (!this.titleCache.has(key)) {
this.titleCache.set(key, s);
}
}
}
return this.titleCache.get(needle);
}
private resolveXRef(sheetTitle: string, key: string): XRefResolution {
const target = this.findSheetByTitle(sheetTitle);
if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` };
if (this.resolving.has(target.id)) {
return { ok: false, reason: "circular reference" };
}
const exports = this.exportsFor(target);
if (key === "total") {
return exports.total
? { ok: true, value: exports.total }
: { ok: false, reason: `sheet "${sheetTitle}" has no total` };
}
if (key === "last") {
return exports.last
? { ok: true, value: exports.last }
: { ok: false, reason: `sheet "${sheetTitle}" has no result` };
}
const v = exports.vars.get(key);
if (!v) return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` };
return { ok: true, value: v };
}
private exportsFor(sheet: SheetSource): SheetExports {
const cached = this.cache.get(sheet.id);
if (cached) return cached;
const results = this.doEvaluate(sheet.id, sheet.text);
const vars = new Map<string, Value>();
for (const r of results) {
if (r.assign && r.value) vars.set(r.assign, r.value);
}
let last: Value | null = null;
for (let i = results.length - 1; i >= 0; i--) {
if (results[i].kind === "normal" && results[i].value) {
last = results[i].value;
break;
}
}
const exports: SheetExports = { vars, total: this.engine.totalValueOf(results), last, results };
// A cycle-interrupted pass produces incomplete/wrong exports (dropped
// assignments, missing total) — don't let it poison the cache. Re-evaluate
// fresh on every query until the cycle is actually broken by an edit.
if (!results.some((r) => r.error === "circular reference")) {
this.cache.set(sheet.id, exports);
}
return exports;
}
}