Compare commits

...

26 commits
v1.2.8 ... main

Author SHA1 Message Date
Horizontal42
bede889ced ci: fix tauri --ci flag in woodpecker 2026-09-20 02:35:05 +03:00
Horizontal42
617a295084 ci: add clippy component in rust container 2026-09-20 02:26:02 +03:00
Horizontal42
8de1925be6 ci: add woodpecker pipeline for build and tests 2026-09-20 02:22:50 +03:00
Horizontal42
37cfd2144b update docs: fix test count (373 not 259), add missing files to arch tree
- vitest actually runs 373 cases across 21 files, not 259
- added workspace.ts, sparkline.ts, logger.ts to the file tree in ARCHITECTURE
- fixes in all four language variants: README + ARCHITECTURE in EN and RU
2026-08-23 18:45:27 +03:00
Horizontal42
663b2ccab8 sync package-lock.json with 1.2.9 2026-08-19 16:52:30 +03:00
Horizontal42
a4b69ec9e7 sync Cargo.lock with 1.2.9 2026-08-19 16:50:11 +03:00
Horizontal42
b91f650c31 update tauri.conf version to 1.2.9 2026-08-19 16:49:57 +03:00
Horizontal42
1cd9807543 bump cargo version to 1.2.9 2026-08-19 16:49:50 +03:00
Horizontal42
942f963c9d bump version to 1.2.9 2026-08-19 16:49:43 +03:00
Horizontal42
786dbe3daa
Refactor evalCall to use BUILTIN_FUNCS lookup object (#103)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:47:06 +03:00
Horizontal42
70492159e6
⚡ Unnecessary DOM writes in drag mousemove event (#98)
* perf: Optimize DOM writes in drag mousemove event

Optimizes the drag-and-drop `mousemove` event handler by caching the previously hovered element. The `drag-over` class is now only removed from `prevOver` and added to the new hover target, instead of looping through all `cachedItems` to remove the class on every move. This eliminates layout thrashing and high CPU usage during drag operations.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>

* drop stray benchmark script

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:44:07 +03:00
Horizontal42
203c4b4efd
refactor: use switch statement in parser loop for readability (#101)
Replaces the deeply nested if-chain inside the Pratt parser loop in `src/engine/parser.ts` with a `switch` statement over token types. Introduces a labeled `loop: for(;;)` to maintain accurate `continue`/`break` semantics while greatly enhancing the visual parsing and extensibility of the logic without hurting evaluation speed.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:36:38 +03:00
Horizontal42
34d2defa82
Refactor: Extract removeDocById helper for document filtering (#112)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:36:35 +03:00
Horizontal42
12ae91da2e
perf(engine): reuse Decimal(0) and Decimal(1) instances (#106)
Added exported constants `DEC_ZERO` and `DEC_ONE` in `src/engine/types.ts`
and replaced all instances of `new Decimal(0)` and `new Decimal(1)`
across `src/engine/evaluator.ts` with these constants.
This reduces unnecessary memory allocations and garbage collection
overhead in the evaluator engine, especially during hot loops like
goal seek (root finding).

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:36:10 +03:00
Horizontal42
1e80c55011
Refactor parseLine logic in src/engine/parser.ts (#100)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:36:05 +03:00
Horizontal42
62f55b10a9
Extract getDocById helper function (#97)
- Extracts `data.docs.find((d) => d.id === ...)` into a helper function to deduplicate finding document logic.
- Cleans up an unused `data` parameter on `setupDocInteraction`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:36:02 +03:00
Horizontal42
5158771aeb
Add test suite for application main entry point (#114)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:49 +03:00
Horizontal42
8c50fc8aa9
🔒 [security fix] Remove unsafe path canonicalization in directory gate (#111)
* 🔒 [security fix] Remove unsafe path canonicalization in directory gate

🎯 What:
Removed filesystem-level path canonicalization in the DataDirGate check (`canon_or_raw`).

⚠️ Risk:
An attacker could bypass directory authorization by creating a symlink to an authorized directory, allowing them to redirect arbitrary path reads/writes via TOCTOU (Time-of-Check to Time-of-Use) attacks or misleading unverified paths.

🛡️ Solution:
Replaced `fs::canonicalize` with purely lexical path normalization (resolving `.` and `..` in memory) to ensure strict string match validation without following symlinks. Ensure relative paths with `..` that go above the start are handled securely.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>

* 🔒 [security fix] Remove unsafe path canonicalization in directory gate

🎯 What:
Removed filesystem-level path canonicalization in the DataDirGate check (`canon_or_raw`).

⚠️ Risk:
An attacker could bypass directory authorization by creating a symlink to an authorized directory, allowing them to redirect arbitrary path reads/writes via TOCTOU (Time-of-Check to Time-of-Use) attacks or misleading unverified paths.

🛡️ Solution:
Replaced `fs::canonicalize` with purely lexical path normalization (resolving `.` and `..` in memory) to ensure strict string match validation without following symlinks. Ensure relative paths with `..` that go above the start are handled securely without dropping components incorrectly. Added proper Iterator methods to appease clippy.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:45 +03:00
Horizontal42
ddb51ee9f9
Refactor registerCoreVocab to reduce duplication (#110)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:41 +03:00
Horizontal42
cba2286371
🧹 [Extract evaluateText helper in tests] (#109)
Extracted `evaluateText` helper function in `src/engine/edge.test.ts` to replace the repeated mapping `eng.evaluateDocument(doc).map((x) => x.text)`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:37 +03:00
Horizontal42
19a9631709
perf(workspace): cache sheet list during invalidate loop (#107)
Moved the evaluation of `this.sheets()` outside of the `while (changed)` invalidation loop to avoid redundant evaluations and allocations on deep dependency chains.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:33 +03:00
Horizontal42
d166820b28
test: add coverage for workspace evaluation error cleanup (#105)
Adds a test to `src/workspace.test.ts` to ensure that when `engine.evaluateDocument` throws an exception, the `this.resolving` set is correctly cleaned up in the `finally` block of `doEvaluate`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:29 +03:00
Horizontal42
22302b57cc
perf: defer document string split allocation in numeric search (#104)
In `src/ui/search.ts`, numeric threshold searches previously called `doc.text.split("\n")` eagerly on every open document. This commit changes `lines` to be lazily allocated only if an actual numeric search match is found, eliminating unnecessary memory allocations and loop overhead on documents that do not match.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:26 +03:00
Horizontal42
289ae8aa3f
⚡ [perf: avoid expensive doc stringification for autocomplete] (#102)
* perf: avoid expensive doc stringification for autocomplete

Refactored the autocomplete variable matching logic in `src/ui/editor.ts` to iterate through document lines using CodeMirror's `iterLines()` instead of allocating the entire document string in memory with `toString()`. Also pulled the regex up and introduced a quick `.includes("=")` bailout to skip the regex engine completely for most lines.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>

* drop stray benchmark script

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:21 +03:00
Horizontal42
d528aa9bbb
test: add unit tests for logger utility (#96)
Adds a test file `src/logger.test.ts` to verify that `logger.info`, `logger.warn`, and `logger.error` correctly call the underlying console methods with the passed arguments.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-08-19 16:35:18 +03:00
Horizontal42
868a34497b cargo fmt 2026-08-19 04:17:58 +03:00
23 changed files with 1076 additions and 393 deletions

22
.woodpecker.yaml Normal file
View file

@ -0,0 +1,22 @@
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

@ -23,13 +23,16 @@ src/
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)
main.ts app bootstrap: documents, settings, status bar, dnd
workspace.ts cross-sheet references: exports cache, cycle detection
storage.ts Tauri commands / localStorage fallback
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
to the engine's numi.* API
i18n.ts interface strings (en/ru)
updater.ts checks tauri-plugin-updater, installs + relaunches
logger.ts thin console wrapper (info/warn/error)
src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
@ -300,7 +303,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
## Tests
`npm test` runs 259 vitest cases: `src/engine/*.test.ts` covers every
`npm test` runs 373 vitest cases across 21 files: `src/engine/*.test.ts` covers every
expression class, both languages, deterministic injected rates, goal seek,
historical rates (injected), date-format literals/display (dedicated engines
with an explicit `dateFormat`, since the default "system" format depends on

View file

@ -24,13 +24,16 @@ src/
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
search.ts оверлей поиска по всем листам (текст + запросы по значению)
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
sparkline.ts рендер SVG-спарклайна для результатов `chart`
app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd
workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов
storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы в песочнице
QuickJS/WASM, мост к API numi.* движка
i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск
logger.ts тонкая обёртка над console (info/warn/error)
src-tauri/
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
@ -304,7 +307,7 @@ src-tauri/
## Тесты
`npm test` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
неизвестного, исторические курсы (инжектированные), литералы и отображение
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по

View file

@ -169,7 +169,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash
npm install
npm test # engine tests (259 cases)
npm test # engine tests (373 cases)
npm run tauri dev # run the app
npm run tauri build # build the installer
```

View file

@ -165,7 +165,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
```bash
npm install
npm test # тесты движка (259 кейсов)
npm test # тесты движка (373 кейса)
npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик
```

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "summarum",
"version": "1.2.8",
"version": "1.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "summarum",
"version": "1.2.8",
"version": "1.2.9",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0",

View file

@ -1,7 +1,7 @@
{
"name": "summarum",
"private": true,
"version": "1.2.8",
"version": "1.2.9",
"description": "Summarum — natural language calculator notebook",
"type": "module",
"scripts": {

2
src-tauri/Cargo.lock generated
View file

@ -3727,7 +3727,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "summarum"
version = "1.2.8"
version = "1.2.9"
dependencies = [
"chrono",
"futures",

View file

@ -1,6 +1,6 @@
[package]
name = "summarum"
version = "1.2.8"
version = "1.2.9"
description = "Summarum — natural language calculator notebook"
edition = "2021"

View file

@ -46,7 +46,25 @@ struct DataDirGate {
}
fn canon_or_raw(p: &Path) -> PathBuf {
fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
let mut normalized = PathBuf::new();
for component in p.components() {
match component {
std::path::Component::ParentDir => {
let can_pop = matches!(
normalized.components().next_back(),
Some(std::path::Component::Normal(_))
);
if can_pop {
normalized.pop();
} else if !normalized.has_root() {
normalized.push(component);
}
}
std::path::Component::CurDir => {}
_ => normalized.push(component),
}
}
normalized
}
/// Exact match against an authorized folder, compared canonicalized so a
@ -565,11 +583,7 @@ async fn fetch_historical_rates(
// api.frankfurter.app 301-redirects here now, and the old domain is
// flaky over reqwest/rustls-tls — go straight to the stable host
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?;
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
@ -628,7 +642,8 @@ async fn fetch_historical_rates_batch(
if let Ok(body) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Some(rates_obj) = body["rates"].as_object() {
let daily_rates = parse_rate_map(rates_obj);
let cache_path = data_dir(&app_clone).join(format!("rates-{}.json", date));
let cache_path =
data_dir(&app_clone).join(format!("rates-{}.json", date));
if let Ok(serialized) = serde_json::to_string(&daily_rates) {
write_atomic_async(cache_path, serialized).await;
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Summarum",
"version": "1.2.8",
"version": "1.2.9",
"identifier": "app.summarum.calc",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -13,6 +13,10 @@ function calc(expr: string): string | null {
return r[r.length - 1].text;
}
function evaluateText(doc: string): (string | null)[] {
return eng.evaluateDocument(doc).map((x) => x.text);
}
describe("edge cases", () => {
it("russian mixed sentence", () => {
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
@ -45,11 +49,11 @@ describe("edge cases", () => {
expect(calc("hello world")).toBe(null);
});
it("sum stops at header", () => {
const r = eng.evaluateDocument("5\n# section\n10\n20\nsum").map((x) => x.text);
const r = evaluateText("5\n# section\n10\n20\nsum");
expect(r).toEqual(["5", null, "10", "20", "30"]);
});
it("variables with units survive math", () => {
const r = eng.evaluateDocument("rent = $500\nfood = $200\nrent + food").map((x) => x.text);
const r = evaluateText("rent = $500\nfood = $200\nrent + food");
expect(r).toEqual(["$500", "$200", "$700"]);
});
it("big numbers with grouping", () => {
@ -145,7 +149,7 @@ describe("crypto", () => {
describe("regressions", () => {
it("a broken line does not kill the document (BigInt from NaN)", () => {
const r = eng.evaluateDocument("2+2\nasin(5) & 3\n3+3").map((x) => x.text);
const r = evaluateText("2+2\nasin(5) & 3\n3+3");
expect(r).toEqual(["4", null, "6"]);
});
it("non-finite results are suppressed", () => {
@ -178,8 +182,8 @@ describe("regressions", () => {
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
});
it("single-letter unit names can be variables", () => {
expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]);
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]);
});
it("tiny conversion results show significant digits, not 0", () => {
expect(calc("1 mm in km")).toBe("0.000001 km");
@ -199,7 +203,7 @@ describe("regressions", () => {
expect(calc("4 ^ 50%")).toBe("2");
});
it("avg skips incompatible lines in the denominator", () => {
expect(eng.evaluateDocument("10 kg\n20 kg\n5 hours\navg").map((x) => x.text)).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
expect(evaluateText("10 kg\n20 kg\n5 hours\navg")).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
});
it("calendar month add clamps to month end", () => {
const doc = eng.evaluateDocument("today + 1 month");
@ -286,7 +290,7 @@ describe("review fixes", () => {
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 = eng.evaluateDocument(`1+1\n${deep}\n2+2`).map((x) => x.text);
const r = evaluateText(`1+1\n${deep}\n2+2`);
expect(r[0]).toBe("2");
expect(r[1]).toBeNull();
expect(r[2]).toBe("4");
@ -297,9 +301,9 @@ describe("regression 2026-08-12", () => {
expect(calc("5 мин")).toBe("5 min");
});
it("standalone 'min'/'max' aggregate lines still work", () => {
const r = eng.evaluateDocument("10\n20\n5\nmin").map((x) => x.text);
const r = evaluateText("10\n20\n5\nmin");
expect(r).toEqual(["10", "20", "5", "5"]);
const r2 = eng.evaluateDocument("10\n20\n5\nmax").map((x) => x.text);
const r2 = evaluateText("10\n20\n5\nmax");
expect(r2).toEqual(["10", "20", "5", "20"]);
});

View file

@ -1,9 +1,14 @@
// Walks the AST with the document context (variables, line results for
// sum/avg/prev) and produces unit-aware values.
import { Decimal, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types";
import { Decimal, DEC_ZERO, DEC_ONE, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types";
import { Node, ConvTarget } from "./parser";
import { Registry } from "./registry";
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
import {
resolveZone,
startOfToday,
addToDate,
isCalendarUnit,
} from "./datetime";
export interface LineCtx {
/** values of lines above (null = no result) */
@ -25,7 +30,9 @@ export interface EvalCtx {
resolveXRef?: XRefResolver;
}
export type XRefResolution = { ok: true; value: Value } | { ok: false; reason: string };
export type XRefResolution =
| { ok: true; value: Value }
| { ok: false; reason: string };
export type XRefResolver = (sheet: string, key: string) => XRefResolution;
const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
@ -57,7 +64,12 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "curr":
return evaluateUnitMod(node, ctx);
case "bin":
return evalBin(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx), ctx);
return evalBin(
node.op,
evaluate(node.l, ctx),
evaluate(node.r, ctx),
ctx,
);
case "bit":
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
case "pctop":
@ -65,7 +77,11 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "conv":
return evalConv(evaluate(node.x, ctx), node.target, ctx);
case "call":
return evalCall(node.name, node.args.map((a) => evaluate(a, ctx)), ctx);
return evalCall(
node.name,
node.args.map((a) => evaluate(a, ctx)),
ctx,
);
case "seq":
return evaluateSeq(node, ctx);
}
@ -112,7 +128,8 @@ export function fromBase(base: Decimal, unit: Unit): Decimal {
export function convertQ(q: Quantity, unit: Unit): Quantity {
if (!q.unit) return { ...q, unit };
if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
if (q.unit.dimension !== unit.dimension)
throw new EvalError("dimension mismatch");
return qty(fromBase(toBase(q), unit), unit);
}
@ -122,36 +139,52 @@ function addQ(a: Quantity, b: Quantity): Quantity {
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
if (a.unit && b.unit) {
if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
if (a.unit.dimension !== b.unit.dimension)
throw new EvalError("dimension mismatch");
if (a.unit.offset || b.unit.offset) {
// temperatures: operate in the left unit's scale
const bInA = convertQ(b, a.unit);
return qty(sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value), a.unit);
return qty(
sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value),
a.unit,
);
}
const base = sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
const base =
sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
return qty(fromBase(base, a.unit), a.unit);
}
const unit = a.unit ?? b.unit ?? null;
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
}
function evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value): Value {
function evalPercentArith(
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
l: Value,
r: Value,
): Value {
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");
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 as { kind: "percent"; value: Decimal }).value;
const p = (
l.kind === "percent" ? l : (r as { kind: "percent"; value: Decimal })
).value;
switch (op) {
case "plus": return qty(q.value.mul(new Decimal(1).add(p.div(100))), q.unit);
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(new Decimal(1).sub(p.div(100))), q.unit);
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");
@ -161,14 +194,22 @@ function evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
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");
default:
throw new EvalError("bad percent op");
}
}
function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", a: Quantity, b: Quantity, ctx: EvalCtx): Value {
function evalQuantityArith(
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
a: Quantity,
b: Quantity,
ctx: EvalCtx,
): Value {
switch (op) {
case "plus": return numericAdd(a, b, 1);
case "minus": return numericAdd(a, b, -1);
case "plus":
return numericAdd(a, b, 1);
case "minus":
return numericAdd(a, b, -1);
case "mul": {
if (a.unit && b.unit) {
const da = a.unit.dimension;
@ -183,7 +224,8 @@ function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
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);
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 ?? b.unit ?? null);
@ -202,16 +244,28 @@ function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
case "pow": {
const result = a.value.pow(b.value);
if (a.unit?.dimension === "length" && !b.unit) {
const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null;
const id = b.value.eq(2)
? `sq:${a.unit.id}`
: b.value.eq(3)
? `cb:${a.unit.id}`
: null;
const u = id ? ctx.reg.unitsById.get(id) : null;
if (u) return qty(result, u);
}
return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null);
return qty(
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 {
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);
}
@ -224,19 +278,33 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value,
}
function evalBit(op: string, l: Value, r: Value): Value {
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
if (l.kind !== "quantity" || r.kind !== "quantity")
throw new EvalError("bitwise needs 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 b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n))
throw new EvalError("bad shift");
let out: bigint;
switch (op) {
case "band": out = a & b; break;
case "bor": out = a | b; break;
case "bxor": out = a ^ b; break;
case "shl": out = a << b; break;
case "shr": out = a >> b; break;
default: throw new EvalError("bad bit op");
case "band":
out = a & b;
break;
case "bor":
out = a | b;
break;
case "bxor":
out = a ^ b;
break;
case "shl":
out = a << b;
break;
case "shr":
out = a >> b;
break;
default:
throw new EvalError("bad bit op");
}
const repr = l.repr !== "decimal" ? l.repr : r.repr;
return qty(new Decimal(out.toString()), null, repr);
@ -257,8 +325,8 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
const p = asPercentValue(l);
const factor =
op === "of" ? p.div(100)
: op === "off" ? new Decimal(1).sub(p.div(100))
: new Decimal(1).add(p.div(100));
: op === "off" ? DEC_ONE.sub(p.div(100))
: DEC_ONE.add(p.div(100));
// a percent of a percent stays a percent: 50% of 50% = 25%
if (r.kind === "percent") return pct(r.value.mul(factor));
return qty(rv.mul(factor), unit);
@ -268,20 +336,21 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
if (rv.isZero()) throw new EvalError("division by zero");
switch (op) {
case "as_pct_of": return pct(lv.div(rv).mul(100));
case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100));
case "as_pct_off": return pct(DEC_ONE.sub(lv.div(rv)).mul(100));
case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
}
}
// "20% of what is 30"
const p = asPercentValue(l);
switch (op) {
case "of_what_is": return qty(rv.div(p.div(100)), unit);
case "of_what_is":
return qty(rv.div(p.div(100)), unit);
case "off_what_is": {
const d = new Decimal(1).sub(p.div(100));
const d = DEC_ONE.sub(p.div(100));
if (d.isZero()) throw new EvalError("division by zero");
return qty(rv.div(d), unit);
}
case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
case "on_what_is": return qty(rv.div(DEC_ONE.add(p.div(100))), unit);
}
throw new EvalError(`bad percent op ${op}`);
}
@ -290,7 +359,8 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
switch (target.type) {
case "unit": {
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit");
if (v.kind !== "quantity")
throw new EvalError("cannot convert date to unit");
if (!v.unit) return qty(v.value, target.unit);
return convertQ(v, target.unit);
}
@ -299,13 +369,17 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
if (target.onDate) {
const dateRates = ctx.historicalRates?.get(target.onDate);
const rate = dateRates?.get(target.code);
if (rate === undefined) throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`);
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.kind !== "quantity")
throw new EvalError("cannot convert to currency");
if (!v.unit) return qty(v.value, unit);
return convertQ(v, unit);
}
@ -324,13 +398,17 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
return { ...v, timeZone: zone };
}
case "unix": {
if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
if (v.kind === "date")
return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
throw new EvalError("unix needs a date");
}
case "todate": {
if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number");
if (v.kind !== "quantity" || v.unit)
throw new EvalError("date needs a plain number");
// heuristics: > 1e12 means milliseconds, else seconds
const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber();
const ms = v.value.gt(1e12)
? v.value.toNumber()
: v.value.mul(1000).toNumber();
return { kind: "date", ms, hasTime: true };
}
}
@ -338,7 +416,8 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
function evalDateWord(word: string): Value {
switch (word) {
case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
case "today":
return { kind: "date", ms: startOfToday(), hasTime: false };
case "tomorrow": {
const d = new Date(startOfToday());
d.setDate(d.getDate() + 1);
@ -349,9 +428,13 @@ function evalDateWord(word: string): Value {
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}`);
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}`);
}
}
@ -359,10 +442,16 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
if (l.kind === "date" && r.kind === "date") {
if (op !== "minus") throw new EvalError("dates only subtract");
// midnight-to-midnight differences count calendar days (robust across DST)
const days = !l.hasTime && !r.hasTime
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
: new Decimal(l.ms - r.ms).div(86400_000);
return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
const days =
!l.hasTime && !r.hasTime
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
: new Decimal(l.ms - r.ms).div(86400_000);
return qty(days, {
id: "day",
dimension: "time",
ratio: new Decimal(86400),
format: "day",
});
}
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
const span = l.kind === "date" ? r : l;
@ -370,7 +459,8 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
throw new EvalError("date arithmetic needs a time span");
}
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
if (op === "minus" && l.kind !== "date")
throw new EvalError("cannot subtract date from number");
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
let ms: number;
let hasTime = date.hasTime;
@ -392,23 +482,28 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
const innerCtx: EvalCtx = { ...ctx, vars };
const lv = evaluate(lhsNode, innerCtx);
const rv = evaluate(rhsNode, innerCtx);
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : new Decimal(0);
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; }
try {
return evalF(x);
} catch {
return null;
}
};
const f0 = safeEval(new Decimal(0));
const f1 = safeEval(new Decimal(1));
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);
if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9")))
return qty(root);
}
}
@ -417,20 +512,33 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
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];
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 (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");
}
@ -440,11 +548,16 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
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");
lo = mid.plus("1e-9");
continue;
}
if (flo!.mul(fm).lte(0)) { hi = mid; fhi = fm; }
else { lo = mid; flo = fm; }
if (flo!.mul(fm).lte(0)) {
hi = mid;
fhi = fm;
} else {
lo = mid;
flo = fm;
}
}
return qty(lo.plus(hi).div(2));
}
@ -456,7 +569,10 @@ function filterSameDimension(block: Quantity[]): Quantity[] {
}
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */
function reduceCompatible(block: Quantity[]): { acc: Quantity; counted: number } {
function reduceCompatible(block: Quantity[]): {
acc: Quantity;
counted: number;
} {
let acc = block[0];
let counted = 1;
for (let i = 1; i < block.length; i++) {
@ -474,7 +590,8 @@ 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");
if (compatible.length < 2)
throw new EvalError("need at least 2 values for chart");
return {
kind: "chart",
points: compatible.map((q) => toBase(q)),
@ -483,12 +600,12 @@ const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
},
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), new Decimal(1))),
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 {
@ -517,7 +634,10 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
return block;
}
function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value {
function evalAgg(
name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart",
ctx: EvalCtx,
): Value {
if (name === "prev") return getPrevResult(ctx);
const block = getAggBlock(ctx);
const func = AGG_FUNCS[name];
@ -525,6 +645,80 @@ function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "produ
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());
},
cbrt: (x) => qty(asScalar(x).cbrt()),
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 {
const custom = ctx.reg.customFuncs.get(name);
if (custom) return custom(args);
@ -533,53 +727,9 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
const x = args[0];
if (!x) throw new EvalError(`${name} needs an argument`);
// trig works on angles: degrees convert to radians
const angleArg = (): Decimal => {
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const n = () => asScalar(x);
switch (name) {
case "sqrt": {
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
if (base) return qty(x.value.sqrt(), base);
}
return qty(n().sqrt());
}
case "cbrt": return qty(n().cbrt());
case "sin": return qty(Decimal.sin(angleArg()));
case "cos": return qty(Decimal.cos(angleArg()));
case "tan": return qty(Decimal.tan(angleArg()));
case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
case "asin": case "arcsin": return qty(Decimal.asin(n()));
case "acos": case "arccos": return qty(Decimal.acos(n()));
case "atan": case "arctan": return qty(Decimal.atan(n()));
case "sinh": return qty(Decimal.sinh(n()));
case "cosh": return qty(Decimal.cosh(n()));
case "tanh": return qty(Decimal.tanh(n()));
case "ln": return qty(Decimal.ln(n()));
case "lg": case "log": return qty(Decimal.log10(n()));
case "log2": return qty(Decimal.log2(n()));
case "exp": return qty(Decimal.exp(n()));
case "abs": return x.kind === "quantity" ? { ...x, value: x.value.abs() } : qty(n().abs());
case "round": return x.kind === "quantity" ? { ...x, value: x.value.round() } : qty(n().round());
case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
case "fact": case "factorial": return qty(factorial(n()));
case "until": {
if (x.kind !== "date") throw new EvalError("until needs a date");
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
}
case "since": {
if (x.kind !== "date") throw new EvalError("since needs a date");
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
}
default: throw new EvalError(`unknown function ${name}`);
}
const func = BUILTIN_FUNCS[name];
if (!func) throw new EvalError(`unknown function ${name}`);
return func(x, ctx);
}
// Native float, not Decimal: this is a PRNG (mulberry32/strHash bit-twiddling
@ -589,10 +739,15 @@ function evalRandom(args: Value[], ctx: EvalCtx): Value {
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
const rand = mulberry32(seed);
if (args.length === 0) return qty(new Decimal(rand));
const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0;
const [lo, hi] = args.length === 1 ? [0, toNum(args[0]!)] : [toNum(args[0]!), toNum(args[1]!)];
const toNum = (v: Value) => (v.kind === "quantity" ? v.value.toNumber() : 0);
const [lo, hi] =
args.length === 1
? [0, toNum(args[0]!)]
: [toNum(args[0]!), toNum(args[1]!)];
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
const raw = isInt
? Math.floor(rand * (hi - lo + 1)) + lo
: rand * (hi - lo) + lo;
return qty(new Decimal(raw));
}
@ -605,25 +760,32 @@ function strHash(s: string): number {
}
function mulberry32(seed: number): number {
seed = (seed + 0x6D2B79F5) | 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function factorial(n: Decimal): Decimal {
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
let acc = new Decimal(1);
let acc = DEC_ONE;
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
return acc;
}
function evaluateConst(node: { k: "const", name: "pi" | "e" | "half" | "onehalf" }): Value {
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"));
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"));
}
}
@ -636,7 +798,9 @@ function evaluateRef(node: Node, ctx: EvalCtx): Value {
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`);
throw new XRefError(
res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`,
);
}
return res.value;
}
@ -688,7 +852,7 @@ function evaluateUnitMod(node: Node, ctx: EvalCtx): Value {
throw new EvalError("invalid unit mod node");
}
function evaluateSeq(node: { k: "seq", items: Node[] }, ctx: EvalCtx): Value {
function evaluateSeq(node: { k: "seq"; items: Node[] }, ctx: EvalCtx): Value {
let acc: Value | null = null;
for (const item of node.items) {
let v: Value;
@ -698,8 +862,11 @@ function evaluateSeq(node: { k: "seq", items: Node[] }, ctx: EvalCtx): Value {
continue;
}
if (
acc?.kind === "quantity" && v.kind === "quantity" &&
acc.unit && v.unit && acc.unit.dimension === v.unit.dimension &&
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

View file

@ -62,6 +62,18 @@ 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
// a conversion operator (potential timezone names).
const filtered: Token[] = [];
@ -81,7 +93,10 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
afterConv = tk.t === "conv";
filtered.push(tk);
}
return filtered;
}
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) {
@ -89,12 +104,10 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
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 { expr: { k: "goalseek", lhs, rhs } };
if (lhs && rhs) return { k: "goalseek", lhs, rhs };
}
}
const p = new Parser(filtered, knownVars);
return { assign, expr: p.parseSeq() };
return null;
}
@ -204,114 +217,131 @@ class Parser {
if (!lhs) return null;
for (;;) {
loop: for (;;) {
tk = this.peek();
if (!tk) break;
if (tk.t === "percent") {
if (PREC.POSTFIX < minPrec) break;
this.i++;
lhs = { k: "pct", x: lhs };
continue;
}
if (tk.t === "bang") {
if (PREC.POSTFIX < minPrec) break;
this.i++;
lhs = { k: "fact", x: lhs };
continue;
}
if (tk.t === "unit") {
if (PREC.POSTFIX < minPrec) break;
this.i++;
lhs = { k: "unit", x: lhs, unit: tk.unit };
continue;
}
if (tk.t === "currency") {
if (PREC.POSTFIX < minPrec) break;
this.i++;
lhs = { k: "curr", x: lhs, code: tk.code };
continue;
}
if (tk.t === "scale") {
if (PREC.POSTFIX < minPrec) break;
this.i++;
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
continue;
}
if (tk.t === "op" && tk.op === "pow") {
if (PREC.POW < minPrec) break;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.POW);
if (!r) { this.i = save; break; }
lhs = { k: "bin", op: "pow", l: lhs, r };
continue;
}
if (tk.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) {
if (PREC.MUL < minPrec) break;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break; }
lhs = { k: "bin", op: tk.op, l: lhs, r };
continue;
}
if (tk.t === "pctop" && (tk.op === "of" || tk.op === "off" || tk.op === "on")) {
if (!(lhs.k === "pct" || lhs.k === "var" || lhs.k === "pctop")) {
switch (tk.t) {
case "percent": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
continue;
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;
}
if (PREC.MUL < minPrec) break;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break; }
lhs = { k: "pctop", op: tk.op, l: lhs, r };
continue;
}
if (tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw))) {
if (PREC.MUL < minPrec) break;
const save = this.i;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break; }
lhs = { k: "bin", op: "mul", l: lhs, r };
continue;
}
if (tk.t === "op" && (tk.op === "plus" || tk.op === "minus")) {
if (PREC.ADD < minPrec) break;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD + 1);
if (!r) { this.i = save; break; }
lhs = { k: "bin", op: tk.op, l: lhs, r };
continue;
}
if (tk.t === "bitop") {
if (PREC.BIT < minPrec) break;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.BIT + 1);
if (!r) { this.i = save; break; }
lhs = { k: "bit", op: tk.op, l: lhs, r };
continue;
}
if (tk.t === "conv") {
if (PREC.CONV < minPrec) break;
const save = this.i;
this.i++;
const target = this.parseTarget();
if (!target) { this.i = save; break; }
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
continue;
}
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
if (PREC.CONV < minPrec) break;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD);
if (!r) { this.i = save; break; }
lhs = { k: "pctop", op: tk.op, l: lhs, r };
continue;
}
break;

View file

@ -178,29 +178,29 @@ function registerCoreVocab(reg: Registry): void {
["minus", { t: "op", op: "minus" }],
["multiply", { t: "op", op: "mul" }],
["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 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", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "chart.variants")) reg.addPhrase(v, { t: "agg", name: "chart" }, { 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 });
for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false });
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });

View file

@ -6,6 +6,8 @@ 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 =
| "scalar"

30
src/logger.test.ts Normal file
View file

@ -0,0 +1,30 @@
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);
});
});

127
src/main.test.ts Normal file
View file

@ -0,0 +1,127 @@
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();
});

View file

@ -4,12 +4,32 @@ import "./ui/app.css";
import { SumEngine } from "./engine";
import { SumEditor } from "./ui/editor";
import {
AppData, DocMeta, SettingsData, defaultSettingsData,
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri,
getLaunchFile, onOpenFile, onFileDrop,
setDataDir, runBackups, backupDeletedSheet, openBackupsFolder,
chooseFolder, dataDirHasDocuments, migrateDataDir,
AppData,
DocMeta,
SettingsData,
defaultSettingsData,
loadAppData,
saveAppData,
flushAppData,
onAppQuit,
loadSettings,
saveSettings,
fetchRates,
fetchMarketData,
fetchHistoricalRatesBatch,
loadExtensionScripts,
openExtensionsFolder,
isTauri,
getLaunchFile,
onOpenFile,
onFileDrop,
setDataDir,
runBackups,
backupDeletedSheet,
openBackupsFolder,
chooseFolder,
dataDirHasDocuments,
migrateDataDir,
} from "./storage";
import type { LineResult } from "./engine";
import { setLang, detectLang, t } from "./i18n";
@ -23,10 +43,13 @@ import pkg from "../package.json";
function welcomeText(lang: string): string {
const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"];
return (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n";
return (
(sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n"
);
}
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
const $ = <T extends HTMLElement>(sel: string): T =>
document.querySelector(sel) as T;
let engine: SumEngine;
let editor: SumEditor;
@ -46,8 +69,12 @@ function uid(): string {
// ---------- documents
function getDocById(id: string | undefined): DocMeta | undefined {
return data.docs.find((d) => d.id === id);
}
function activeDoc(): DocMeta {
return data.docs.find((d) => d.id === data.activeId) ?? data.docs[0];
return getDocById(data.activeId) ?? data.docs[0];
}
function titleFromContent(text: string): string {
@ -67,21 +94,34 @@ function syncTitleField(): void {
if (document.activeElement !== field) field.value = activeDoc()?.title ?? "";
}
function removeDocById(id: string): void {
data.docs = data.docs.filter((d) => d.id !== id);
}
function pinDoc(id: string): void {
const doc = data.docs.find((d) => d.id === id);
const doc = getDocById(id);
if (!doc) return;
doc.pinned = !doc.pinned;
if (doc.pinned) {
// move to end of pinned group
data.docs = data.docs.filter((d) => d.id !== id);
removeDocById(id);
let lastPinned = -1;
for (let i = data.docs.length - 1; i >= 0; i--) { if (data.docs[i].pinned) { lastPinned = i; break; } }
for (let i = data.docs.length - 1; i >= 0; i--) {
if (data.docs[i].pinned) {
lastPinned = i;
break;
}
}
data.docs.splice(lastPinned + 1, 0, doc);
} else {
// move to start of non-pinned group
data.docs = data.docs.filter((d) => d.id !== id);
removeDocById(id);
const firstNonPinned = data.docs.findIndex((d) => !d.pinned);
data.docs.splice(firstNonPinned === -1 ? data.docs.length : firstNonPinned, 0, doc);
data.docs.splice(
firstNonPinned === -1 ? data.docs.length : firstNonPinned,
0,
doc,
);
}
saveAppData(data);
renderDocList();
@ -90,17 +130,17 @@ function pinDoc(id: string): void {
/** drag id -> drop-target id; no-op across the pinned/unpinned boundary */
function reorderDoc(srcId: string, targetId: string): void {
if (srcId === targetId) return;
const src = data.docs.find((d) => d.id === srcId);
const target = data.docs.find((d) => d.id === targetId);
const src = getDocById(srcId);
const target = getDocById(targetId);
if (!src || !target || !!src.pinned !== !!target.pinned) return;
data.docs = data.docs.filter((d) => d.id !== srcId);
removeDocById(srcId);
const targetIdx = data.docs.findIndex((d) => d.id === targetId);
data.docs.splice(targetIdx, 0, src);
saveAppData(data);
renderDocList();
}
function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, data: AppData): void {
function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement): void {
// Reordering uses plain mouse tracking, not the native HTML5 drag API —
// Tauri's window-level file-drop hook (dragDropEnabled, needed for
// dropping .numi files from Explorer) intercepts WebView2's own drag
@ -111,7 +151,9 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
el.addEventListener("mousedown", (e) => {
if ((e.target as HTMLElement).closest("button")) return;
const startY = e.clientY;
let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null;
let cachedItems: { el: HTMLElement; top: number; bottom: number }[] | null =
null;
let prevOver: HTMLElement | undefined;
const onMove = (ev: MouseEvent) => {
if (!cachedItems) {
const domItems = [...list.querySelectorAll<HTMLElement>(".doc-item")];
@ -129,19 +171,28 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
}
let over: HTMLElement | undefined;
for (const item of cachedItems) {
item.el.classList.remove("drag-over");
if (!over && item.el !== el) {
if (ev.clientY >= item.top && ev.clientY <= item.bottom) {
over = item.el;
}
}
}
let newOver: HTMLElement | undefined;
if (over) {
const overDoc = data.docs.find((d) => d.id === over.dataset.docId);
if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over");
const overDoc = getDocById(over.dataset.docId);
if (overDoc && !!overDoc.pinned === !!doc.pinned) newOver = over;
}
if (prevOver && prevOver !== newOver) {
prevOver.classList.remove("drag-over");
}
if (newOver && prevOver !== newOver) {
newOver.classList.add("drag-over");
}
prevOver = newOver;
};
const onScroll = () => {
cachedItems = null;
};
const onScroll = () => { cachedItems = null; };
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
@ -149,7 +200,8 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
cachedItems = null;
if (dragging) {
const target = list.querySelector<HTMLElement>(".doc-item.drag-over");
for (const item of list.querySelectorAll(".doc-item")) item.classList.remove("drag-over", "dragging");
for (const item of list.querySelectorAll(".doc-item"))
item.classList.remove("drag-over", "dragging");
document.body.style.cursor = "";
if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId);
}
@ -160,22 +212,37 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
list.addEventListener("scroll", onScroll);
});
el.addEventListener("click", () => {
if (suppressClick) { suppressClick = false; return; }
if (suppressClick) {
suppressClick = false;
return;
}
switchDoc(doc.id);
});
}
function mkBtn(cls: string, text: string, title: string, cb: () => void): HTMLButtonElement {
function mkBtn(
cls: string,
text: string,
title: string,
cb: () => void,
): HTMLButtonElement {
const b = document.createElement("button");
b.className = cls;
b.textContent = text;
b.title = title;
b.setAttribute("aria-label", title);
b.addEventListener("click", (e) => { e.stopPropagation(); cb(); });
b.addEventListener("click", (e) => {
e.stopPropagation();
cb();
});
return b;
}
function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: SettingsData): HTMLButtonElement {
function createDeleteButton(
doc: DocMeta,
appData: AppData,
appSettings: SettingsData,
): HTMLButtonElement {
const del = document.createElement("button");
del.className = "del";
del.textContent = "✕";
@ -193,9 +260,13 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting
return;
}
if (confirmTimer) clearTimeout(confirmTimer);
void backupDeletedSheet(appSettings.dataDir, doc.title, appData.contents[doc.id] ?? "");
void backupDeletedSheet(
appSettings.dataDir,
doc.title,
appData.contents[doc.id] ?? "",
);
delete appData.contents[doc.id];
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
removeDocById(doc.id);
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
else renderDocList();
saveAppData(appData);
@ -209,15 +280,25 @@ function renderDocList(): void {
const frag = document.createDocumentFragment();
for (const doc of data.docs) {
const el = document.createElement("div");
el.className = "doc-item" + (doc.id === data.activeId ? " active" : "") + (doc.pinned ? " pinned" : "");
el.className =
"doc-item" +
(doc.id === data.activeId ? " active" : "") +
(doc.pinned ? " pinned" : "");
const name = document.createElement("span");
name.className = "doc-name";
name.textContent = doc.title || t("untitled");
el.appendChild(name);
el.appendChild(mkBtn("pin-btn" + (doc.pinned ? " active" : ""), "📌", doc.pinned ? t("unpin") : t("pin"), () => pinDoc(doc.id)));
el.appendChild(
mkBtn(
"pin-btn" + (doc.pinned ? " active" : ""),
"📌",
doc.pinned ? t("unpin") : t("pin"),
() => pinDoc(doc.id),
),
);
setupDocInteraction(el, doc, list, data);
setupDocInteraction(el, doc, list);
el.dataset.docId = doc.id;
el.appendChild(createDeleteButton(doc, data, settings));
@ -239,14 +320,17 @@ function switchDoc(id: string): void {
function newDoc(content = ""): void {
const id = uid();
data.docs.push({ id, title: content ? titleFromContent(content) : t("untitled") });
data.docs.push({
id,
title: content ? titleFromContent(content) : t("untitled"),
});
data.contents[id] = content;
switchDoc(id);
}
async function closeActiveDoc(): Promise<void> {
if (data.docs.length <= 1) return;
const doc = data.docs.find((d) => d.id === data.activeId);
const doc = getDocById(data.activeId);
if (!doc) return;
const content = data.contents[doc.id] ?? "";
if (content.trim()) {
@ -256,8 +340,8 @@ async function closeActiveDoc(): Promise<void> {
const idx = data.docs.findIndex((d) => d.id === doc.id);
void backupDeletedSheet(settings.dataDir, doc.title, content);
delete data.contents[doc.id];
data.docs = data.docs.filter((d) => d.id !== doc.id);
const nextId = data.docs[idx] ? data.docs[idx].id : data.docs[idx - 1]?.id ?? data.docs[0].id;
removeDocById(doc.id);
const nextId = data.docs[idx] ? data.docs[idx].id : (data.docs[idx - 1]?.id ?? data.docs[0].id);
switchDoc(nextId);
saveAppData(data);
}
@ -265,12 +349,21 @@ async function closeActiveDoc(): Promise<void> {
// ---------- settings
function applySettings(): void {
const theme = settings.theme === "system"
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: settings.theme;
const theme =
settings.theme === "system"
? matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: settings.theme;
document.documentElement.dataset.theme = theme;
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
document.documentElement.style.setProperty(
"--editor-font-size",
`${settings.fontSize}px`,
);
$("#editor-wrap").style.setProperty(
"--results-width",
`${settings.resultsWidth}%`,
);
setLang(settings.language);
const searchInput = $<HTMLInputElement>("#search-input");
searchInput.placeholder = t("searchPlaceholder");
@ -283,7 +376,10 @@ function applySettings(): void {
});
editor?.refresh();
syncTitleField();
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
$<HTMLElement>("#sidebar").classList.toggle(
"hidden",
!settings.sidebarVisible,
);
}
function bindFormattingSettings(): void {
@ -307,7 +403,10 @@ function bindFormattingSettings(): void {
const save = () => {
settings.theme = themeSel.value as SettingsData["theme"];
settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2));
settings.precision = Math.max(
0,
Math.min(15, Number(precision.value) || 2),
);
settings.groupSeparator = groupSep.value;
settings.decimalSeparator = decimalSep.value;
// "1,234,56" is unreadable — a comma decimal forces a space group separator
@ -317,13 +416,27 @@ function bindFormattingSettings(): void {
}
settings.dateFormat = dateFmt.value as SettingsData["dateFormat"];
settings.language = langSel.value;
settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15));
settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42));
settings.fontSize = Math.max(
10,
Math.min(32, Number(fontSize.value) || 15),
);
settings.resultsWidth = Math.max(
20,
Math.min(60, Number(resultsWidth.value) || 42),
);
applySettings();
void saveSettings(settings);
};
for (const el of [themeSel, precision, groupSep, decimalSep, dateFmt, langSel, fontSize]) {
for (const el of [
themeSel,
precision,
groupSep,
decimalSep,
dateFmt,
langSel,
fontSize,
]) {
el.addEventListener("change", save);
}
resultsWidth.addEventListener("input", save); // live while sliding
@ -402,7 +515,10 @@ function bindBehaviorSettings(): void {
void saveSettings(settings);
});
binDays.addEventListener("change", () => {
settings.deletedRetentionDays = Math.max(1, Math.min(365, Math.round(Number(binDays.value)) || 14));
settings.deletedRetentionDays = Math.max(
1,
Math.min(365, Math.round(Number(binDays.value)) || 14),
);
binDays.value = String(settings.deletedRetentionDays);
void saveSettings(settings);
void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away
@ -418,7 +534,9 @@ function bindBehaviorSettings(): void {
function bindDataDirSettings(): void {
const dataDirBtn = $<HTMLButtonElement>("#set-datadir");
const renderDataDir = () => {
dataDirBtn.textContent = settings.dataDir ? settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir : t("defaultFolder");
dataDirBtn.textContent = settings.dataDir
? (settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir)
: t("defaultFolder");
dataDirBtn.title = settings.dataDir || t("defaultFolder");
};
renderDataDir();
@ -428,7 +546,11 @@ function bindDataDirSettings(): void {
let strategy: "move" | "overwrite" | "use_existing" = "move";
try {
if (await dataDirHasDocuments(picked)) {
const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine"));
const ans = await askModal(
t("folderConflict"),
t("useExisting"),
t("replaceMine"),
);
if (ans === null) return;
strategy = ans === "a" ? "use_existing" : "overwrite";
}
@ -449,7 +571,8 @@ function bindDataDirSettings(): void {
const fresh = await loadAppData(picked);
if (fresh && fresh.docs.length > 0) {
data = fresh;
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
if (!data.docs.some((d) => d.id === data.activeId))
data.activeId = data.docs[0].id;
switchDoc(data.activeId);
}
}
@ -458,12 +581,19 @@ function bindDataDirSettings(): void {
}
function bindSettingsNavigation(): void {
const tabs = document.querySelectorAll<HTMLButtonElement>("#settings-tabs .tab");
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
const tabs = document.querySelectorAll<HTMLButtonElement>(
"#settings-tabs .tab",
);
const tabPanels =
document.querySelectorAll<HTMLElement>(".settings-tabpanel");
for (const tab of tabs) {
tab.addEventListener("click", () => {
for (const other of tabs) other.classList.toggle("active", other === tab);
for (const panel of tabPanels) panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab);
for (const panel of tabPanels)
panel.classList.toggle(
"hidden",
panel.dataset.tabpanel !== tab.dataset.tab,
);
});
}
@ -473,8 +603,14 @@ function bindSettingsNavigation(): void {
$("#close-settings").addEventListener("click", () => {
$("#settings-panel").classList.add("hidden");
});
$("#open-extensions").addEventListener("click", () => void openExtensionsFolder());
$("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir));
$("#open-extensions").addEventListener(
"click",
() => void openExtensionsFolder(),
);
$("#open-backups").addEventListener(
"click",
() => void openBackupsFolder(settings.dataDir),
);
$("#check-update").addEventListener("click", () => void checkUpdate(true));
}
@ -504,18 +640,39 @@ function normalizeKeyName(e: KeyboardEvent): string | null {
if (/^Numpad\d$/.test(code)) return code;
if (/^F\d{1,2}$/.test(code)) return code;
const map: Record<string, string> = {
Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right",
Minus: "-", Equal: "=", Comma: ",", Period: ".", Slash: "/", Backquote: "`",
BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'",
Space: "Space",
Enter: "Enter",
Backspace: "Backspace",
Delete: "Delete",
Tab: "Tab",
Home: "Home",
End: "End",
PageUp: "PageUp",
PageDown: "PageDown",
ArrowUp: "Up",
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right",
Minus: "-",
Equal: "=",
Comma: ",",
Period: ".",
Slash: "/",
Backquote: "`",
BracketLeft: "[",
BracketRight: "]",
Semicolon: ";",
Quote: "'",
};
return map[code] ?? null;
}
// ---------- tauri integration
async function registerHotkey(old: string | null, combo: string): Promise<boolean> {
async function registerHotkey(
old: string | null,
combo: string,
): Promise<boolean> {
if (!isTauri()) return true;
try {
const gs = await import("@tauri-apps/plugin-global-shortcut");
@ -557,7 +714,9 @@ async function applyAutostart(enabled: boolean): Promise<void> {
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
if (!isTauri()) return;
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => logger.warn("always-on-top failed", e));
await getCurrentWindow()
.setAlwaysOnTop(enabled)
.catch((e) => logger.warn("always-on-top failed", e));
}
async function refreshRates(force = false): Promise<void> {
@ -567,7 +726,7 @@ async function refreshRates(force = false): Promise<void> {
info.classList.remove("spin");
if (payload) {
liveRates = Object.fromEntries(
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)])
Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]),
);
applyAllRates();
workspace.invalidateAll();
@ -581,7 +740,15 @@ async function refreshRates(force = false): Promise<void> {
// ---------- market data
// Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss.
const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"];
const MARKET_SYMBOLS = [
"AAPL",
"MSFT",
"GOOGL",
"AMZN",
"TSLA",
"NVDA",
"META",
];
let marketPrices: Record<string, number> = {};
let marketFetchedAt = 0;
@ -620,7 +787,9 @@ const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi;
async function fetchNeededHistoricalRates(text: string): Promise<void> {
const dates = new Set<string>();
for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!);
const datesToFetch = Array.from(dates).filter((date) => !engine.hasHistoricalRates(date));
const datesToFetch = Array.from(dates).filter(
(date) => !engine.hasHistoricalRates(date),
);
let fetched = false;
if (datesToFetch.length > 0) {
const results = await fetchHistoricalRatesBatch(datesToFetch);
@ -632,12 +801,18 @@ async function fetchNeededHistoricalRates(text: string): Promise<void> {
}
}
}
if (fetched) { workspace.invalidateAll(); editor.refresh(); }
if (fetched) {
workspace.invalidateAll();
editor.refresh();
}
}
function renderMarketInfo(): void {
const el = $("#market-info") as HTMLElement;
if (marketFetchedAt === 0) { el.classList.add("d-none"); return; }
if (marketFetchedAt === 0) {
el.classList.add("d-none");
return;
}
el.classList.remove("d-none");
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
el.title = t("refreshRates");
@ -673,7 +848,8 @@ function agoText(unixSec: number): string {
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
if (s < 90) return t("justNow");
if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
if (s < 86400) return t("hourAgo").replace("{}", String(Math.round(s / 3600)));
if (s < 86400)
return t("hourAgo").replace("{}", String(Math.round(s / 3600)));
return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
}
@ -695,13 +871,21 @@ let modalChain: Promise<unknown> = Promise.resolve();
/** two-button modal; resolves "a" | "b" | null (click outside = cancel).
calls serialize on the shared #modal singleton — a second call waits for
the first to resolve rather than clobbering its handlers */
function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
function askModal(
msg: string,
aLabel: string,
bLabel: string,
): Promise<"a" | "b" | null> {
const run = modalChain.then(() => showModal(msg, aLabel, bLabel));
modalChain = run.catch(() => {});
return run;
}
function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> {
function showModal(
msg: string,
aLabel: string,
bLabel: string,
): Promise<"a" | "b" | null> {
return new Promise((resolve) => {
const modal = $("#modal");
const a = $("#modal-a");
@ -722,7 +906,8 @@ function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "
// a click that just refocused the window (native dialog closing, or
// bringing a backgrounded tray window to front) lands on the backdrop
// too — ignore a backdrop dismiss that arrives right after refocus
if (e.target === modal && performance.now() - lastWindowFocusAt > 300) done(null);
if (e.target === modal && performance.now() - lastWindowFocusAt > 300)
done(null);
};
});
}
@ -742,7 +927,10 @@ function toast(msg: string): void {
async function initSettings(): Promise<void> {
settings = await loadSettings();
if (!localStorage.getItem("summarum.langInit") && settings.language === "en") {
if (
!localStorage.getItem("summarum.langInit") &&
settings.language === "en"
) {
settings.language = detectLang();
localStorage.setItem("summarum.langInit", "1");
}
@ -762,19 +950,34 @@ async function initDataAndEngine(): Promise<void> {
const stored = await loadAppData(settings.dataDir);
// a corrupt or foreign documents.json must not crash the boot
if (stored && Array.isArray(stored.docs) && stored.docs.length > 0 && stored.contents && typeof stored.contents === "object") {
if (
stored &&
Array.isArray(stored.docs) &&
stored.docs.length > 0 &&
stored.contents &&
typeof stored.contents === "object"
) {
data = stored;
} else {
const id = uid();
data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: welcomeText(settings.language) } };
data = {
docs: [{ id, title: "Sample" }],
activeId: id,
contents: { [id]: welcomeText(settings.language) },
};
}
if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id;
if (!data.docs.some((d) => d.id === data.activeId))
data.activeId = data.docs[0].id;
const scripts = await loadExtensionScripts();
await runExtensions(engine, scripts);
workspace = new Workspace(engine, () =>
data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
data.docs.map((d) => ({
id: d.id,
title: d.title,
text: data.contents[d.id] ?? "",
})),
);
editor = new SumEditor(
@ -817,7 +1020,12 @@ async function initDataAndEngine(): Promise<void> {
search = initSearch({
engine,
workspace,
docs: () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })),
docs: () =>
data.docs.map((d) => ({
id: d.id,
title: d.title,
text: data.contents[d.id] ?? "",
})),
t,
onOpen: (docId, line) => {
if (docId !== data.activeId) switchDoc(docId);
@ -863,7 +1071,9 @@ function bindMiscUI(): void {
$("#open-search").addEventListener("click", () => search.open());
window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); });
window.addEventListener("focus", () => {
lastWindowFocusAt = performance.now();
});
}
async function bindWindowControls(): Promise<void> {
@ -888,7 +1098,11 @@ function bindSidebarUI(): void {
document.addEventListener("mousedown", (e) => {
const target = e.target as HTMLElement;
const panel = $("#settings-panel");
if (!panel.classList.contains("hidden") && !target.closest("#settings-panel") && !target.closest("#open-settings")) {
if (
!panel.classList.contains("hidden") &&
!target.closest("#settings-panel") &&
!target.closest("#open-settings")
) {
panel.classList.add("hidden");
}
if (!settings.sidebarVisible) return;
@ -917,7 +1131,9 @@ function bindDividerUI(): void {
window.removeEventListener("mouseup", onUp);
divider.classList.remove("dragging");
document.body.style.cursor = "";
$<HTMLInputElement>("#set-resultswidth").value = String(settings.resultsWidth);
$<HTMLInputElement>("#set-resultswidth").value = String(
settings.resultsWidth,
);
void saveSettings(settings);
};
window.addEventListener("mousemove", onMove);
@ -960,7 +1176,9 @@ function bindExportUI(): void {
});
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
exportMenu.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action]");
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
"[data-action]",
);
if (!btn) return;
exportMenu.classList.add("hidden");
const action = btn.dataset.action;
@ -982,7 +1200,10 @@ function bindExportUI(): void {
const isSum = action === "save-sum";
const content = isSum ? editor.getText() : editor.getSheetWithResults();
try {
const saved = await invoke<boolean>("write_text_file", { contents: content, isSum });
const saved = await invoke<boolean>("write_text_file", {
contents: content,
isSum,
});
if (saved) {
toast(t("saved"));
}
@ -1032,11 +1253,15 @@ async function bindLifecycle(): Promise<void> {
// update check above only ever runs once at boot — recheck periodically
// so a long-lived process still notices new releases; re-read the setting
// on every tick since the user can flip it while the app is running
setInterval(() => { if (settings.autoUpdateEnabled) void checkUpdate(); }, 6 * 60 * 60 * 1000);
setInterval(
() => {
if (settings.autoUpdateEnabled) void checkUpdate();
},
6 * 60 * 60 * 1000,
);
}
async function initUI(): Promise<void> {
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
bindSettingsUI();
renderDocList();
@ -1070,7 +1295,11 @@ async function checkUpdate(manual = false): Promise<void> {
if (manual) toast(t("upToDate"));
return;
}
const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater"));
const choice = await askModal(
t("updateAvailable").replace("{}", result.version),
t("updateInstall"),
t("updateLater"),
);
if (choice !== "a") return;
toast(t("updateInstalling"));
try {

View file

@ -170,6 +170,7 @@ export class SumEditor {
this.resultsEl = resultsEl;
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 word = ctx.matchBefore(/[\p{L}_]+$/u);
if (!word || (word.from === word.to && !ctx.explicit)) return null;
@ -182,9 +183,15 @@ export class SumEditor {
}));
const options = [...engineOptions];
// document variables
const text = ctx.state.doc.toString();
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
options.push({ label: m[1], type: "variable" });
const iter = ctx.state.doc.iterLines();
for (let next = iter.next(); !next.done; next = iter.next()) {
const line = next.value;
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 };
};

View file

@ -30,9 +30,13 @@ export interface SearchController {
open(): void;
}
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
const $ = <T extends HTMLElement>(sel: string): T =>
document.querySelector(sel) as T;
export function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
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());
@ -50,22 +54,41 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const hits: SearchHit[] = [];
for (const doc of docs) {
const results = deps.workspace.getCachedResults(doc);
const lines = doc.text.split("\n");
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;
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) {
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined });
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;
}
}
@ -78,7 +101,12 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
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] });
hits.push({
docId: doc.id,
docTitle: doc.title,
line: i + 1,
text: lines[i],
});
if (hits.length >= 200) return hits;
}
}
@ -113,7 +141,9 @@ export function initSearch(deps: SearchDeps): SearchController {
docEl.textContent = hit.docTitle;
const lineEl = document.createElement("div");
lineEl.className = "line";
lineEl.textContent = hit.result ? `${hit.text} = ${hit.result}` : hit.text;
lineEl.textContent = hit.result
? `${hit.text} = ${hit.result}`
: hit.text;
item.append(docEl, lineEl);
item.addEventListener("click", () => {
deps.onOpen(hit.docId, hit.line);

View file

@ -44,6 +44,19 @@ describe("Workspace exports", () => {
});
});
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({

View file

@ -54,10 +54,11 @@ export class Workspace {
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 this.sheets()) {
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);