mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 01:02:01 +03:00
feat: add ESLint + Prettier configuration and fix 123 linting errors
- Add ESLint v9+ with TypeScript parser and rules - Add Prettier for code formatting - Add npm scripts: lint, lint:fix, format, format:check - Fix all auto-fixable linting errors (123 errors, 7 warnings) - Improve type safety in editor.ts, storage.ts and test files - Add curly braces to all control statements - Fix unbound method warnings in tests
This commit is contained in:
parent
b399b95263
commit
d8437eddbe
30 changed files with 1704 additions and 459 deletions
55
.gitignore
vendored
55
.gitignore
vendored
|
|
@ -1,20 +1,49 @@
|
||||||
|
```
|
||||||
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
dist/
|
dist/
|
||||||
src-tauri/target/
|
build/
|
||||||
src-tauri/gen/
|
target/
|
||||||
src-tauri/.cargo/
|
|
||||||
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# local reference copies of third-party sources — not ours to ship
|
# Environment
|
||||||
Contents/
|
.env
|
||||||
numi-master/
|
.env.local
|
||||||
|
*.env.*
|
||||||
|
|
||||||
# one-off debugging artifacts
|
# Editors
|
||||||
scripts/smoke-*
|
.vscode/
|
||||||
test_perf.ts
|
.idea/
|
||||||
|
|
||||||
# AI coding agent scratch/notes directories
|
# Python cache (if any Python files exist in project)
|
||||||
.jules/
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
# updater signing key — private, never publish (see .tauri-signing/README.txt)
|
# Coverage reports
|
||||||
.tauri-signing/
|
coverage/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.nyc_output/
|
||||||
|
coverage/
|
||||||
|
junit.xml
|
||||||
|
|
||||||
|
# Prettier and ESLint cache
|
||||||
|
.node_modules/.cache/
|
||||||
|
```
|
||||||
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
31
eslint.config.js
Normal file
31
eslint.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import eslint from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
|
"no-console": "off",
|
||||||
|
curly: ["error", "all"],
|
||||||
|
eqeqeq: ["error", "always"],
|
||||||
|
"prefer-const": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "node_modules/", "*.d.ts"],
|
||||||
|
}
|
||||||
|
);
|
||||||
1254
package-lock.json
generated
1254
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -11,7 +11,11 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"icons": "node scripts/gen-icon.ts",
|
"icons": "node scripts/gen-icon.ts",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
|
|
@ -32,9 +36,16 @@
|
||||||
"quickjs-emscripten-core": "^0.32.0"
|
"quickjs-emscripten-core": "^0.32.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "^2.5.0",
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
"@types/node": "^25.9.2",
|
"@types/node": "^25.9.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||||
|
"@typescript-eslint/parser": "^8.67.0",
|
||||||
|
"eslint": "^10.8.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
"typescript-eslint": "^8.67.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
||||||
import { DateOrder } from "./types";
|
import type { DateOrder } from "./types";
|
||||||
|
|
||||||
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
|
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
|
||||||
export function detectDateOrder(locale?: string): DateOrder {
|
export function detectDateOrder(locale?: string): DateOrder {
|
||||||
const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" })
|
const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" })
|
||||||
.formatToParts(new Date(2000, 11, 31));
|
.formatToParts(new Date(2000, 11, 31));
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
if (p.type === "day") return "dmy";
|
if (p.type === "day") {return "dmy";}
|
||||||
if (p.type === "month") return "mdy";
|
if (p.type === "month") {return "mdy";}
|
||||||
}
|
}
|
||||||
return "dmy";
|
return "dmy";
|
||||||
}
|
}
|
||||||
|
|
@ -79,10 +79,10 @@ const Z: Record<string, string> = {
|
||||||
/** Resolve a sequence of words to an IANA timezone. Tries longest run first. */
|
/** Resolve a sequence of words to an IANA timezone. Tries longest run first. */
|
||||||
export function resolveZone(words: string[]): string | null {
|
export function resolveZone(words: string[]): string | null {
|
||||||
const lower = words.map((w) => w.toLowerCase().replace(/[-.]/g, " ").trim());
|
const lower = words.map((w) => w.toLowerCase().replace(/[-.]/g, " ").trim());
|
||||||
if (lower[0] === "local") return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
if (lower[0] === "local") {return Intl.DateTimeFormat().resolvedOptions().timeZone;}
|
||||||
for (let len = Math.min(lower.length, 3); len >= 1; len--) {
|
for (let len = Math.min(lower.length, 3); len >= 1; len--) {
|
||||||
const key = lower.slice(0, len).join(" ");
|
const key = lower.slice(0, len).join(" ");
|
||||||
if (Z[key]) return Z[key];
|
if (Z[key]) {return Z[key];}
|
||||||
}
|
}
|
||||||
// direct IANA id like Europe/Berlin typed by hand
|
// direct IANA id like Europe/Berlin typed by hand
|
||||||
const joined = words.join("/");
|
const joined = words.join("/");
|
||||||
|
|
|
||||||
|
|
@ -366,13 +366,13 @@ describe("cross-sheet references", () => {
|
||||||
function resolver(sheet: string, key: string): XRefResolution {
|
function resolver(sheet: string, key: string): XRefResolution {
|
||||||
const s = sheet.toLowerCase();
|
const s = sheet.toLowerCase();
|
||||||
if (s === "budget") {
|
if (s === "budget") {
|
||||||
if (key === "total") return { ok: true, value: qty(150) };
|
if (key === "total") {return { ok: true, value: qty(150) };}
|
||||||
if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) };
|
if (key === "rent") {return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")) };}
|
||||||
if (key === "last") return { ok: true, value: qty(7) };
|
if (key === "last") {return { ok: true, value: qty(7) };}
|
||||||
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
|
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
|
||||||
}
|
}
|
||||||
if (s === "trip to lisbon") {
|
if (s === "trip to lisbon") {
|
||||||
if (key === "food") return { ok: true, value: qty(25) };
|
if (key === "food") {return { ok: true, value: qty(25) };}
|
||||||
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
|
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
|
||||||
}
|
}
|
||||||
return { ok: false, reason: `sheet "${sheet}" not found` };
|
return { ok: false, reason: `sheet "${sheet}" not found` };
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { buildRegistry } from "./registry";
|
||||||
const reg = buildRegistry();
|
const reg = buildRegistry();
|
||||||
const getUnit = (id: string) => {
|
const getUnit = (id: string) => {
|
||||||
const u = reg.unitsById.get(id);
|
const u = reg.unitsById.get(id);
|
||||||
if (!u) throw new Error(`unit ${id} not found`);
|
if (!u) {throw new Error(`unit ${id} not found`);}
|
||||||
return u;
|
return u;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Walks the AST with the document context (variables, line results for
|
// Walks the AST with the document context (variables, line results for
|
||||||
// sum/avg/prev) and produces unit-aware values.
|
// sum/avg/prev) and produces unit-aware values.
|
||||||
import { Decimal, EvalError, Quantity, Unit, Value, XRefError, qty, pct } from "./types";
|
import type { Quantity, Unit, Value} from "./types";
|
||||||
import { Node, ConvTarget } from "./parser";
|
import { Decimal, EvalError, XRefError, qty, pct } from "./types";
|
||||||
import { Registry } from "./registry";
|
import type { Node, ConvTarget } from "./parser";
|
||||||
|
import type { Registry } from "./registry";
|
||||||
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
|
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
|
||||||
|
|
||||||
export interface LineCtx {
|
export interface LineCtx {
|
||||||
|
|
@ -75,15 +76,15 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
// ---------- helpers
|
// ---------- helpers
|
||||||
|
|
||||||
function asScalar(v: Value): Decimal {
|
function asScalar(v: Value): Decimal {
|
||||||
if (v.kind === "quantity") return v.value;
|
if (v.kind === "quantity") {return v.value;}
|
||||||
if (v.kind === "percent") return v.value.div(100);
|
if (v.kind === "percent") {return v.value.div(100);}
|
||||||
throw new EvalError("expected a number");
|
throw new EvalError("expected a number");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachUnit(v: Value, unit: Unit): Value {
|
export function attachUnit(v: Value, unit: Unit): Value {
|
||||||
if (v.kind === "quantity") {
|
if (v.kind === "quantity") {
|
||||||
if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit);
|
if (v.unit && v.unit.dimension === unit.dimension) {return convertQ(v, unit);}
|
||||||
if (v.unit) throw new EvalError("unit mismatch");
|
if (v.unit) {throw new EvalError("unit mismatch");}
|
||||||
return { ...v, unit, repr: "decimal" };
|
return { ...v, unit, repr: "decimal" };
|
||||||
}
|
}
|
||||||
throw new EvalError("cannot attach unit");
|
throw new EvalError("cannot attach unit");
|
||||||
|
|
@ -91,29 +92,29 @@ export function attachUnit(v: Value, unit: Unit): Value {
|
||||||
|
|
||||||
/** value of q in dimension-base units */
|
/** value of q in dimension-base units */
|
||||||
export function toBase(q: Quantity): Decimal {
|
export function toBase(q: Quantity): Decimal {
|
||||||
if (!q.unit) return q.value;
|
if (!q.unit) {return q.value;}
|
||||||
if (q.unit.reciprocal) {
|
if (q.unit.reciprocal) {
|
||||||
if (q.value.isZero()) throw new EvalError("division by zero");
|
if (q.value.isZero()) {throw new EvalError("division by zero");}
|
||||||
return q.unit.ratio.div(q.value); // mpg -> L/100km
|
return q.unit.ratio.div(q.value); // mpg -> L/100km
|
||||||
}
|
}
|
||||||
let b = q.value.mul(q.unit.ratio);
|
let b = q.value.mul(q.unit.ratio);
|
||||||
if (q.unit.offset) b = b.add(q.unit.offset);
|
if (q.unit.offset) {b = b.add(q.unit.offset);}
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromBase(base: Decimal, unit: Unit): Decimal {
|
export function fromBase(base: Decimal, unit: Unit): Decimal {
|
||||||
if (unit.reciprocal) {
|
if (unit.reciprocal) {
|
||||||
if (base.isZero()) throw new EvalError("division by zero");
|
if (base.isZero()) {throw new EvalError("division by zero");}
|
||||||
return unit.ratio.div(base);
|
return unit.ratio.div(base);
|
||||||
}
|
}
|
||||||
let v = base;
|
let v = base;
|
||||||
if (unit.offset) v = v.sub(unit.offset);
|
if (unit.offset) {v = v.sub(unit.offset);}
|
||||||
return v.div(unit.ratio);
|
return v.div(unit.ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertQ(q: Quantity, unit: Unit): Quantity {
|
export function convertQ(q: Quantity, unit: Unit): Quantity {
|
||||||
if (!q.unit) return { ...q, unit };
|
if (!q.unit) {return { ...q, unit };}
|
||||||
if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
|
if (q.unit.dimension !== unit.dimension) {throw new EvalError("dimension mismatch");}
|
||||||
return qty(fromBase(toBase(q), unit), unit);
|
return qty(fromBase(toBase(q), unit), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +124,7 @@ function addQ(a: Quantity, b: Quantity): Quantity {
|
||||||
|
|
||||||
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
|
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
|
||||||
if (a.unit && b.unit) {
|
if (a.unit && b.unit) {
|
||||||
if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
|
if (a.unit.dimension !== b.unit.dimension) {throw new EvalError("dimension mismatch");}
|
||||||
if (a.unit.offset || b.unit.offset) {
|
if (a.unit.offset || b.unit.offset) {
|
||||||
// temperatures: operate in the left unit's scale
|
// temperatures: operate in the left unit's scale
|
||||||
const bInA = convertQ(b, a.unit);
|
const bInA = convertQ(b, a.unit);
|
||||||
|
|
@ -151,11 +152,11 @@ function evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
||||||
switch (op) {
|
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(new Decimal(1).add(p.div(100))), q.unit);
|
||||||
case "minus":
|
case "minus":
|
||||||
if (l.kind === "percent") throw new EvalError("percent minus number");
|
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(new Decimal(1).sub(p.div(100))), q.unit);
|
||||||
case "mul": return qty(q.value.mul(p).div(100), q.unit);
|
case "mul": return qty(q.value.mul(p).div(100), q.unit);
|
||||||
case "div":
|
case "div":
|
||||||
if (l.kind === "percent") throw new EvalError("percent div number");
|
if (l.kind === "percent") {throw new EvalError("percent div number");}
|
||||||
return qty(q.value.div(p.div(100)), q.unit);
|
return qty(q.value.div(p.div(100)), q.unit);
|
||||||
case "pow":
|
case "pow":
|
||||||
// which side the percent is on matters: 50% ^ 2 = 0.25, 2 ^ 50% = √2
|
// which side the percent is on matters: 50% ^ 2 = 0.25, 2 ^ 50% = √2
|
||||||
|
|
@ -176,28 +177,28 @@ function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
||||||
const db = b.unit.dimension;
|
const db = b.unit.dimension;
|
||||||
const baseProduct = toBase(a).mul(toBase(b));
|
const baseProduct = toBase(a).mul(toBase(b));
|
||||||
let targetId: string | null = null;
|
let targetId: string | null = null;
|
||||||
if (da === "length" && db === "length") targetId = `sq:${a.unit.id}`;
|
if (da === "length" && db === "length") {targetId = `sq:${a.unit.id}`;}
|
||||||
else if (da === "length" && db === "area") targetId = `cb:${a.unit.id}`;
|
else if (da === "length" && db === "area") {targetId = `cb:${a.unit.id}`;}
|
||||||
else if (da === "area" && db === "length") targetId = `cb:${b.unit.id}`;
|
else if (da === "area" && db === "length") {targetId = `cb:${b.unit.id}`;}
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
const u = ctx.reg.unitsById.get(targetId);
|
const u = ctx.reg.unitsById.get(targetId);
|
||||||
if (u) return qty(baseProduct.div(u.ratio), u);
|
if (u) {return qty(baseProduct.div(u.ratio), u);}
|
||||||
}
|
}
|
||||||
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
|
// 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);
|
||||||
}
|
}
|
||||||
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
|
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
|
||||||
}
|
}
|
||||||
case "div": {
|
case "div": {
|
||||||
if (b.value.isZero()) throw new EvalError("division by zero");
|
if (b.value.isZero()) {throw new EvalError("division by zero");}
|
||||||
if (a.unit && b.unit && a.unit.dimension === b.unit.dimension) {
|
if (a.unit && b.unit && a.unit.dimension === b.unit.dimension) {
|
||||||
return qty(toBase(a).div(toBase(b)));
|
return qty(toBase(a).div(toBase(b)));
|
||||||
}
|
}
|
||||||
return qty(a.value.div(b.value), a.unit ?? null);
|
return qty(a.value.div(b.value), a.unit ?? null);
|
||||||
}
|
}
|
||||||
case "mod": {
|
case "mod": {
|
||||||
if (b.value.isZero()) throw new EvalError("mod zero");
|
if (b.value.isZero()) {throw new EvalError("mod zero");}
|
||||||
return qty(a.value.mod(b.value), a.unit ?? null);
|
return qty(a.value.mod(b.value), a.unit ?? null);
|
||||||
}
|
}
|
||||||
case "pow": {
|
case "pow": {
|
||||||
|
|
@ -205,7 +206,7 @@ function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
||||||
if (a.unit?.dimension === "length" && !b.unit) {
|
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;
|
const u = id ? ctx.reg.unitsById.get(id) : null;
|
||||||
if (u) return qty(result, u);
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -225,11 +226,11 @@ function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalBit(op: string, l: Value, r: Value): Value {
|
function evalBit(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs 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");
|
if (!l.value.isFinite() || !r.value.isFinite()) {throw new EvalError("bitwise needs finite integers");}
|
||||||
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
||||||
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
|
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
|
||||||
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
|
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) {throw new EvalError("bad shift");}
|
||||||
let out: bigint;
|
let out: bigint;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "band": out = a & b; break;
|
case "band": out = a & b; break;
|
||||||
|
|
@ -246,7 +247,7 @@ function evalBit(op: string, l: Value, r: Value): Value {
|
||||||
function evalPctOp(op: string, l: Value, r: Value): Value {
|
function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
const rq = r.kind === "quantity" ? r : null;
|
const rq = r.kind === "quantity" ? r : null;
|
||||||
const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null;
|
const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null;
|
||||||
if (rv === null) throw new EvalError("bad operand");
|
if (rv === null) {throw new EvalError("bad operand");}
|
||||||
const unit = rq?.unit ?? null;
|
const unit = rq?.unit ?? null;
|
||||||
|
|
||||||
if (op === "of" || op === "off" || op === "on") {
|
if (op === "of" || op === "off" || op === "on") {
|
||||||
|
|
@ -256,12 +257,12 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
: op === "off" ? new Decimal(1).sub(p.div(100))
|
: op === "off" ? new Decimal(1).sub(p.div(100))
|
||||||
: new Decimal(1).add(p.div(100));
|
: new Decimal(1).add(p.div(100));
|
||||||
// a percent of a percent stays a percent: 50% of 50% = 25%
|
// a percent of a percent stays a percent: 50% of 50% = 25%
|
||||||
if (r.kind === "percent") return pct(r.value.mul(factor));
|
if (r.kind === "percent") {return pct(r.value.mul(factor));}
|
||||||
return qty(rv.mul(factor), unit);
|
return qty(rv.mul(factor), unit);
|
||||||
}
|
}
|
||||||
if (op === "as_pct_of" || op === "as_pct_off" || op === "as_pct_on") {
|
if (op === "as_pct_of" || op === "as_pct_off" || op === "as_pct_on") {
|
||||||
const lv = l.kind === "quantity" ? l.value : asScalar(l);
|
const lv = l.kind === "quantity" ? l.value : asScalar(l);
|
||||||
if (rv.isZero()) throw new EvalError("division by zero");
|
if (rv.isZero()) {throw new EvalError("division by zero");}
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "as_pct_of": return pct(lv.div(rv).mul(100));
|
case "as_pct_of": return pct(lv.div(rv).mul(100));
|
||||||
case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100));
|
case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100));
|
||||||
|
|
@ -274,7 +275,7 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
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": {
|
case "off_what_is": {
|
||||||
const d = new Decimal(1).sub(p.div(100));
|
const d = new Decimal(1).sub(p.div(100));
|
||||||
if (d.isZero()) throw new EvalError("division by zero");
|
if (d.isZero()) {throw new EvalError("division by zero");}
|
||||||
return qty(rv.div(d), unit);
|
return qty(rv.div(d), unit);
|
||||||
}
|
}
|
||||||
case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
|
case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
|
||||||
|
|
@ -285,9 +286,9 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
switch (target.type) {
|
switch (target.type) {
|
||||||
case "unit": {
|
case "unit": {
|
||||||
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
|
if (v.kind === "percent") {return pct(v.value);} // "x in %" — keep
|
||||||
if (v.kind !== "quantity") 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);
|
if (!v.unit) {return qty(v.value, target.unit);}
|
||||||
return convertQ(v, target.unit);
|
return convertQ(v, target.unit);
|
||||||
}
|
}
|
||||||
case "currency": {
|
case "currency": {
|
||||||
|
|
@ -295,36 +296,36 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
if (target.onDate) {
|
if (target.onDate) {
|
||||||
const dateRates = ctx.historicalRates?.get(target.onDate);
|
const dateRates = ctx.historicalRates?.get(target.onDate);
|
||||||
const rate = dateRates?.get(target.code);
|
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));
|
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
|
||||||
} else {
|
} else {
|
||||||
unit = ctx.reg.makeCurrencyUnit(target.code);
|
unit = ctx.reg.makeCurrencyUnit(target.code);
|
||||||
if (!unit) throw new EvalError(`no rate for ${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);
|
if (!v.unit) {return qty(v.value, unit);}
|
||||||
return convertQ(v, unit);
|
return convertQ(v, unit);
|
||||||
}
|
}
|
||||||
case "repr": {
|
case "repr": {
|
||||||
if (v.kind !== "quantity") throw new EvalError("bad repr conversion");
|
if (v.kind !== "quantity") {throw new EvalError("bad repr conversion");}
|
||||||
return { ...v, repr: target.repr };
|
return { ...v, repr: target.repr };
|
||||||
}
|
}
|
||||||
case "scale": {
|
case "scale": {
|
||||||
if (v.kind !== "quantity") throw new EvalError("bad scale conversion");
|
if (v.kind !== "quantity") {throw new EvalError("bad scale conversion");}
|
||||||
return qty(v.value.div(target.mult), v.unit); // formatter shows plain number
|
return qty(v.value.div(target.mult), v.unit); // formatter shows plain number
|
||||||
}
|
}
|
||||||
case "tz": {
|
case "tz": {
|
||||||
const zone = resolveZone(target.words);
|
const zone = resolveZone(target.words);
|
||||||
if (!zone) throw new EvalError("unknown timezone");
|
if (!zone) {throw new EvalError("unknown timezone");}
|
||||||
if (v.kind !== "date") throw new EvalError("timezone needs a time");
|
if (v.kind !== "date") {throw new EvalError("timezone needs a time");}
|
||||||
return { ...v, timeZone: zone };
|
return { ...v, timeZone: zone };
|
||||||
}
|
}
|
||||||
case "unix": {
|
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");
|
throw new EvalError("unix needs a date");
|
||||||
}
|
}
|
||||||
case "todate": {
|
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
|
// 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 };
|
return { kind: "date", ms, hasTime: true };
|
||||||
|
|
@ -345,7 +346,7 @@ function evalDateWord(word: string): Value {
|
||||||
|
|
||||||
function evalDateArith(op: string, l: Value, r: Value): Value {
|
function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind === "date" && r.kind === "date") {
|
if (l.kind === "date" && r.kind === "date") {
|
||||||
if (op !== "minus") throw new EvalError("dates only subtract");
|
if (op !== "minus") {throw new EvalError("dates only subtract");}
|
||||||
// midnight-to-midnight differences count calendar days (robust across DST)
|
// midnight-to-midnight differences count calendar days (robust across DST)
|
||||||
const days = !l.hasTime && !r.hasTime
|
const days = !l.hasTime && !r.hasTime
|
||||||
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
|
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
|
||||||
|
|
@ -357,8 +358,8 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
if (span.kind !== "quantity" || span.unit?.dimension !== "time") {
|
if (span.kind !== "quantity" || span.unit?.dimension !== "time") {
|
||||||
throw new EvalError("date arithmetic needs a time span");
|
throw new EvalError("date arithmetic needs a time span");
|
||||||
}
|
}
|
||||||
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
|
if (op !== "plus" && op !== "minus") {throw new EvalError("bad date op");}
|
||||||
if (op === "minus" && l.kind !== "date") 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);
|
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
|
||||||
let ms: number;
|
let ms: number;
|
||||||
let hasTime = date.hasTime;
|
let hasTime = date.hasTime;
|
||||||
|
|
@ -368,7 +369,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
const seconds = span.value.mul(span.unit.ratio);
|
const seconds = span.value.mul(span.unit.ratio);
|
||||||
ms = date.ms + seconds.mul(1000).toNumber() * (op === "minus" ? -1 : 1);
|
ms = date.ms + seconds.mul(1000).toNumber() * (op === "minus" ? -1 : 1);
|
||||||
// "today + 2 hours" must show the time part to be visible at all
|
// "today + 2 hours" must show the time part to be visible at all
|
||||||
if (!seconds.mod(86400).isZero()) hasTime = true;
|
if (!seconds.mod(86400).isZero()) {hasTime = true;}
|
||||||
}
|
}
|
||||||
return { ...date, ms, hasTime };
|
return { ...date, ms, hasTime };
|
||||||
}
|
}
|
||||||
|
|
@ -390,7 +391,7 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
|
||||||
const slope = f1.minus(f0);
|
const slope = f1.minus(f0);
|
||||||
if (!slope.isZero()) {
|
if (!slope.isZero()) {
|
||||||
const root = f0.neg().div(slope);
|
const root = f0.neg().div(slope);
|
||||||
if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root);
|
if (evalF(root).abs().lt(new Decimal("1e-9"))) {return qty(root);}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bisection fallback for nonlinear cases
|
// Bisection fallback for nonlinear cases
|
||||||
|
|
@ -398,10 +399,10 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
|
||||||
let hi = new Decimal(1e9);
|
let hi = new Decimal(1e9);
|
||||||
let flo = evalF(lo);
|
let flo = evalF(lo);
|
||||||
let fhi = evalF(hi);
|
let fhi = evalF(hi);
|
||||||
if (flo.mul(fhi).gt(0)) throw new EvalError("no solution");
|
if (flo.mul(fhi).gt(0)) {throw new EvalError("no solution");}
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const mid = lo.plus(hi).div(2);
|
const mid = lo.plus(hi).div(2);
|
||||||
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
|
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) {return qty(mid);}
|
||||||
const fm = evalF(mid);
|
const fm = evalF(mid);
|
||||||
if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; }
|
if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; }
|
||||||
else { lo = mid; flo = fm; }
|
else { lo = mid; flo = fm; }
|
||||||
|
|
@ -414,7 +415,7 @@ const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
|
||||||
chart: (block) => {
|
chart: (block) => {
|
||||||
const refDim = block[0].unit?.dimension ?? null;
|
const refDim = block[0].unit?.dimension ?? null;
|
||||||
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
|
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
|
||||||
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 {
|
return {
|
||||||
kind: "chart",
|
kind: "chart",
|
||||||
points: compatible.map((q) => toBase(q)),
|
points: compatible.map((q) => toBase(q)),
|
||||||
|
|
@ -462,7 +463,7 @@ function getPrevResult(ctx: EvalCtx): Value {
|
||||||
const { lineValues, index } = ctx.line;
|
const { lineValues, index } = ctx.line;
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
const v = lineValues[i];
|
const v = lineValues[i];
|
||||||
if (v) return v;
|
if (v) {return v;}
|
||||||
}
|
}
|
||||||
throw new EvalError("no previous result");
|
throw new EvalError("no previous result");
|
||||||
}
|
}
|
||||||
|
|
@ -472,33 +473,33 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
|
||||||
const block: Quantity[] = [];
|
const block: Quantity[] = [];
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
if (lineKinds[i] === "empty" || lineKinds[i] === "header") {
|
if (lineKinds[i] === "empty" || lineKinds[i] === "header") {
|
||||||
if (block.length > 0) break;
|
if (block.length > 0) {break;}
|
||||||
if (lineKinds[i] === "header") break;
|
if (lineKinds[i] === "header") {break;}
|
||||||
continue; // skip empties directly above until the block starts
|
continue; // skip empties directly above until the block starts
|
||||||
}
|
}
|
||||||
const v = lineValues[i];
|
const v = lineValues[i];
|
||||||
if (v?.kind === "quantity") block.push(v);
|
if (v?.kind === "quantity") {block.push(v);}
|
||||||
}
|
}
|
||||||
if (block.length === 0) throw new EvalError("nothing to aggregate");
|
if (block.length === 0) {throw new EvalError("nothing to aggregate");}
|
||||||
block.reverse();
|
block.reverse();
|
||||||
return block;
|
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);
|
if (name === "prev") {return getPrevResult(ctx);}
|
||||||
const block = getAggBlock(ctx);
|
const block = getAggBlock(ctx);
|
||||||
const func = AGG_FUNCS[name];
|
const func = AGG_FUNCS[name];
|
||||||
if (!func) throw new EvalError(`unknown aggregate function ${name}`);
|
if (!func) {throw new EvalError(`unknown aggregate function ${name}`);}
|
||||||
return func(block);
|
return func(block);
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
const custom = ctx.reg.customFuncs.get(name);
|
const custom = ctx.reg.customFuncs.get(name);
|
||||||
if (custom) return custom(args);
|
if (custom) {return custom(args);}
|
||||||
if (name === "random") return evalRandom(args, ctx); // random() is valid with no args
|
if (name === "random") {return evalRandom(args, ctx);} // random() is valid with no args
|
||||||
|
|
||||||
const x = args[0];
|
const x = args[0];
|
||||||
if (!x) throw new EvalError(`${name} needs an argument`);
|
if (!x) {throw new EvalError(`${name} needs an argument`);}
|
||||||
|
|
||||||
// trig works on angles: degrees convert to radians
|
// trig works on angles: degrees convert to radians
|
||||||
const angleArg = (): Decimal => {
|
const angleArg = (): Decimal => {
|
||||||
|
|
@ -513,7 +514,7 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
case "sqrt": {
|
case "sqrt": {
|
||||||
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
|
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
|
||||||
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
|
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
|
||||||
if (base) return qty(x.value.sqrt(), base);
|
if (base) {return qty(x.value.sqrt(), base);}
|
||||||
}
|
}
|
||||||
return qty(n().sqrt());
|
return qty(n().sqrt());
|
||||||
}
|
}
|
||||||
|
|
@ -538,11 +539,11 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
|
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
|
||||||
case "fact": case "factorial": return qty(factorial(n()));
|
case "fact": case "factorial": return qty(factorial(n()));
|
||||||
case "until": {
|
case "until": {
|
||||||
if (x.kind !== "date") throw new EvalError("until needs a date");
|
if (x.kind !== "date") {throw new EvalError("until needs a date");}
|
||||||
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
|
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
|
||||||
}
|
}
|
||||||
case "since": {
|
case "since": {
|
||||||
if (x.kind !== "date") throw new EvalError("since needs a date");
|
if (x.kind !== "date") {throw new EvalError("since needs a date");}
|
||||||
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
|
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
|
||||||
}
|
}
|
||||||
default: throw new EvalError(`unknown function ${name}`);
|
default: throw new EvalError(`unknown function ${name}`);
|
||||||
|
|
@ -552,9 +553,9 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
function evalRandom(args: Value[], ctx: EvalCtx): Value {
|
function evalRandom(args: Value[], ctx: EvalCtx): Value {
|
||||||
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
|
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
|
||||||
const rand = mulberry32(seed);
|
const rand = mulberry32(seed);
|
||||||
if (args.length === 0) return qty(new Decimal(rand));
|
if (args.length === 0) {return qty(new Decimal(rand));}
|
||||||
const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0;
|
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 [lo, hi] = args.length === 1 ? [0, toNum(args[0])] : [toNum(args[0]), toNum(args[1])];
|
||||||
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
|
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));
|
return qty(new Decimal(raw));
|
||||||
|
|
@ -576,9 +577,9 @@ function mulberry32(seed: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorial(n: Decimal): Decimal {
|
function factorial(n: Decimal): Decimal {
|
||||||
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
|
if (!n.isInteger() || n.isNegative() || n.gt(300)) {throw new EvalError("bad factorial");}
|
||||||
let acc = new Decimal(1);
|
let acc = new Decimal(1);
|
||||||
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
|
for (let i = 2; i <= n.toNumber(); i++) {acc = acc.mul(i);}
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,7 +595,7 @@ function evaluateConst(node: { k: "const", name: "pi" | "e" | "half" | "onehalf"
|
||||||
function evaluateRef(node: Node, ctx: EvalCtx): Value {
|
function evaluateRef(node: Node, ctx: EvalCtx): Value {
|
||||||
if (node.k === "var") {
|
if (node.k === "var") {
|
||||||
const v = ctx.vars.get(node.name);
|
const v = ctx.vars.get(node.name);
|
||||||
if (!v) throw new EvalError(`unknown variable ${node.name}`);
|
if (!v) {throw new EvalError(`unknown variable ${node.name}`);}
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
if (node.k === "xref") {
|
if (node.k === "xref") {
|
||||||
|
|
@ -606,7 +607,7 @@ function evaluateRef(node: Node, ctx: EvalCtx): Value {
|
||||||
}
|
}
|
||||||
if (node.k === "unknown") {
|
if (node.k === "unknown") {
|
||||||
const xv = ctx.vars.get("__x__");
|
const xv = ctx.vars.get("__x__");
|
||||||
if (!xv) throw new EvalError("? used outside goal seek");
|
if (!xv) {throw new EvalError("? used outside goal seek");}
|
||||||
return xv;
|
return xv;
|
||||||
}
|
}
|
||||||
throw new EvalError("invalid ref node");
|
throw new EvalError("invalid ref node");
|
||||||
|
|
@ -615,14 +616,14 @@ function evaluateRef(node: Node, ctx: EvalCtx): Value {
|
||||||
function evaluateMath(node: Node, ctx: EvalCtx): Value {
|
function evaluateMath(node: Node, ctx: EvalCtx): Value {
|
||||||
if (node.k === "neg") {
|
if (node.k === "neg") {
|
||||||
const v = evaluate(node.x, ctx);
|
const v = evaluate(node.x, ctx);
|
||||||
if (v.kind === "quantity") return { ...v, value: v.value.neg() };
|
if (v.kind === "quantity") {return { ...v, value: v.value.neg() };}
|
||||||
if (v.kind === "percent") return pct(v.value.neg());
|
if (v.kind === "percent") {return pct(v.value.neg());}
|
||||||
throw new EvalError("cannot negate a date");
|
throw new EvalError("cannot negate a date");
|
||||||
}
|
}
|
||||||
if (node.k === "pct") {
|
if (node.k === "pct") {
|
||||||
const v = evaluate(node.x, ctx);
|
const v = evaluate(node.x, ctx);
|
||||||
if (v.kind === "quantity" && !v.unit) return pct(v.value);
|
if (v.kind === "quantity" && !v.unit) {return pct(v.value);}
|
||||||
if (v.kind === "percent") return v;
|
if (v.kind === "percent") {return v;}
|
||||||
throw new EvalError("% needs a plain number");
|
throw new EvalError("% needs a plain number");
|
||||||
}
|
}
|
||||||
if (node.k === "fact") {
|
if (node.k === "fact") {
|
||||||
|
|
@ -631,8 +632,8 @@ function evaluateMath(node: Node, ctx: EvalCtx): Value {
|
||||||
}
|
}
|
||||||
if (node.k === "scale") {
|
if (node.k === "scale") {
|
||||||
const v = evaluate(node.x, ctx);
|
const v = evaluate(node.x, ctx);
|
||||||
if (v.kind === "quantity") return { ...v, value: v.value.mul(node.mult) };
|
if (v.kind === "quantity") {return { ...v, value: v.value.mul(node.mult) };}
|
||||||
if (v.kind === "percent") return pct(v.value.mul(node.mult));
|
if (v.kind === "percent") {return pct(v.value.mul(node.mult));}
|
||||||
throw new EvalError("cannot scale a date");
|
throw new EvalError("cannot scale a date");
|
||||||
}
|
}
|
||||||
throw new EvalError("invalid math node");
|
throw new EvalError("invalid math node");
|
||||||
|
|
@ -645,7 +646,7 @@ function evaluateUnitMod(node: Node, ctx: EvalCtx): Value {
|
||||||
}
|
}
|
||||||
if (node.k === "curr") {
|
if (node.k === "curr") {
|
||||||
const unit = ctx.reg.makeCurrencyUnit(node.code);
|
const unit = ctx.reg.makeCurrencyUnit(node.code);
|
||||||
if (!unit) throw new EvalError(`no rate for ${node.code}`);
|
if (!unit) {throw new EvalError(`no rate for ${node.code}`);}
|
||||||
const v = evaluate(node.x, ctx);
|
const v = evaluate(node.x, ctx);
|
||||||
return attachUnit(v, unit);
|
return attachUnit(v, unit);
|
||||||
}
|
}
|
||||||
|
|
@ -672,6 +673,6 @@ function evaluateSeq(node: { k: "seq", items: Node[] }, ctx: EvalCtx): Value {
|
||||||
acc = v;
|
acc = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!acc) throw new EvalError("empty expression");
|
if (!acc) {throw new EvalError("empty expression");}
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { formatNumber, formatValue } from "./formatter";
|
import { formatNumber, formatValue } from "./formatter";
|
||||||
import { Decimal, defaultSettings, qty, pct, DateVal, ChartValue, EngineSettings } from "./types";
|
import type { DateVal, ChartValue, EngineSettings } from "./types";
|
||||||
|
import { Decimal, defaultSettings, qty, pct } from "./types";
|
||||||
|
|
||||||
describe("formatNumber", () => {
|
describe("formatNumber", () => {
|
||||||
it("formats standard numbers", () => {
|
it("formats standard numbers", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Turns values into display strings: unit/currency formats plus
|
// Turns values into display strings: unit/currency formats plus
|
||||||
// precision and grouping settings.
|
// precision and grouping settings.
|
||||||
import { Decimal, DateVal, EngineSettings, Value } from "./types";
|
import type { DateVal, EngineSettings, Value } from "./types";
|
||||||
|
import { Decimal } from "./types";
|
||||||
|
|
||||||
// Intl.DateTimeFormat construction is relatively expensive and every line with
|
// Intl.DateTimeFormat construction is relatively expensive and every line with
|
||||||
// a date rebuilds one on each render — cache by locale+options instead.
|
// a date rebuilds one on each render — cache by locale+options instead.
|
||||||
|
|
@ -28,10 +29,10 @@ function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function group(intPart: string, sep: string): string {
|
function group(intPart: string, sep: string): string {
|
||||||
if (!sep) return intPart;
|
if (!sep) {return intPart;}
|
||||||
const neg = intPart.startsWith("-");
|
const neg = intPart.startsWith("-");
|
||||||
const digits = neg ? intPart.slice(1) : intPart;
|
const digits = neg ? intPart.slice(1) : intPart;
|
||||||
if (digits.length < 4) return intPart;
|
if (digits.length < 4) {return intPart;}
|
||||||
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
|
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
|
||||||
return (neg ? "-" : "") + grouped;
|
return (neg ? "-" : "") + grouped;
|
||||||
}
|
}
|
||||||
|
|
@ -44,14 +45,14 @@ export function formatNumber(v: Decimal, s: EngineSettings): string {
|
||||||
rounded = v.toSignificantDigits(Math.max(s.precision, 1));
|
rounded = v.toSignificantDigits(Math.max(s.precision, 1));
|
||||||
}
|
}
|
||||||
const str = rounded.toString();
|
const str = rounded.toString();
|
||||||
if (str.includes("e") || str.includes("E")) return str;
|
if (str.includes("e") || str.includes("E")) {return str;}
|
||||||
const [int, frac] = str.split(".");
|
const [int, frac] = str.split(".");
|
||||||
const intG = group(int, s.groupSeparator);
|
const intG = group(int, s.groupSeparator);
|
||||||
return frac !== undefined ? intG + s.decimalSeparator + frac : intG;
|
return frac !== undefined ? intG + s.decimalSeparator + frac : intG;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(v: Value, s: EngineSettings): string {
|
export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
if (v.kind === "chart") return "";
|
if (v.kind === "chart") {return "";}
|
||||||
|
|
||||||
if (v.kind === "percent") {
|
if (v.kind === "percent") {
|
||||||
return formatNumber(v.value, s) + "%";
|
return formatNumber(v.value, s) + "%";
|
||||||
|
|
@ -62,7 +63,7 @@ export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
if (v.timeOnly) {
|
if (v.timeOnly) {
|
||||||
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
||||||
}
|
}
|
||||||
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
|
if (s.dateFormat !== "system") {return formatDateFixed(v, s.dateFormat);}
|
||||||
if (v.hasTime) {
|
if (v.hasTime) {
|
||||||
return dtf(undefined, {
|
return dtf(undefined, {
|
||||||
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
||||||
|
|
@ -96,7 +97,7 @@ export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const num = formatNumber(v.value, s);
|
const num = formatNumber(v.value, s);
|
||||||
if (!v.unit) return num;
|
if (!v.unit) {return num;}
|
||||||
return withUnit(num, v.unit.format);
|
return withUnit(num, v.unit.format);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,27 +113,27 @@ function withUnit(num: string, f: string): string {
|
||||||
function formatFraction(v: Decimal): string {
|
function formatFraction(v: Decimal): string {
|
||||||
const neg = v.isNegative();
|
const neg = v.isNegative();
|
||||||
const x = v.abs().toNumber();
|
const x = v.abs().toNumber();
|
||||||
if (!Number.isFinite(x)) return v.toString();
|
if (!Number.isFinite(x)) {return v.toString();}
|
||||||
let h1 = 1, h0 = 0, k1 = 0, k0 = 1, b = x;
|
let h1 = 1, h0 = 0, k1 = 0, k0 = 1, b = x;
|
||||||
for (let i = 0; i < 40; i++) {
|
for (let i = 0; i < 40; i++) {
|
||||||
const a = Math.floor(b);
|
const a = Math.floor(b);
|
||||||
[h1, h0] = [a * h1 + h0, h1];
|
[h1, h0] = [a * h1 + h0, h1];
|
||||||
[k1, k0] = [a * k1 + k0, k1];
|
[k1, k0] = [a * k1 + k0, k1];
|
||||||
if (k1 > 10000) break;
|
if (k1 > 10000) {break;}
|
||||||
if (Math.abs(x - h1 / k1) < 1e-12) break;
|
if (Math.abs(x - h1 / k1) < 1e-12) {break;}
|
||||||
const frac = b - a;
|
const frac = b - a;
|
||||||
if (frac < 1e-12) break;
|
if (frac < 1e-12) {break;}
|
||||||
b = 1 / frac;
|
b = 1 / frac;
|
||||||
}
|
}
|
||||||
const sign = neg ? "-" : "";
|
const sign = neg ? "-" : "";
|
||||||
if (k1 === 1) return sign + String(h1);
|
if (k1 === 1) {return sign + String(h1);}
|
||||||
const whole = Math.floor(h1 / k1);
|
const whole = Math.floor(h1 / k1);
|
||||||
const rem = h1 - whole * k1;
|
const rem = h1 - whole * k1;
|
||||||
return whole > 0 ? `${sign}${whole} ${rem}/${k1}` : `${sign}${rem}/${k1}`;
|
return whole > 0 ? `${sign}${whole} ${rem}/${k1}` : `${sign}${rem}/${k1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRoman(v: Decimal): string | null {
|
function formatRoman(v: Decimal): string | null {
|
||||||
if (!v.isInteger() || v.lt(1) || v.gt(3999)) return null;
|
if (!v.isInteger() || v.lt(1) || v.gt(3999)) {return null;}
|
||||||
let n = v.toNumber();
|
let n = v.toNumber();
|
||||||
const table: Array<[number, string]> = [
|
const table: Array<[number, string]> = [
|
||||||
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
|
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
// Public facade: evaluates whole documents line by line and exposes the
|
// Public facade: evaluates whole documents line by line and exposes the
|
||||||
// extension API (numi.addUnit / addFunction / setVariable).
|
// extension API (numi.addUnit / addFunction / setVariable).
|
||||||
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
import type { DateOrder, EngineSettings, Quantity, Unit, Value} from "./types";
|
||||||
import { Registry, buildRegistry, Completion } from "./registry";
|
import { Decimal, EvalError, XRefError, defaultSettings, qty } from "./types";
|
||||||
import { tokenize, Token } from "./tokenizer";
|
import type { Registry, Completion } from "./registry";
|
||||||
|
import { buildRegistry } from "./registry";
|
||||||
|
import type { Token } from "./tokenizer";
|
||||||
|
import { tokenize } from "./tokenizer";
|
||||||
import { parseLine } from "./parser";
|
import { parseLine } from "./parser";
|
||||||
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
|
import type { EvalCtx, XRefResolver} from "./evaluator";
|
||||||
|
import { evaluate, toBase, fromBase } from "./evaluator";
|
||||||
import { formatValue } from "./formatter";
|
import { formatValue } from "./formatter";
|
||||||
import { detectDateOrder } from "./datetime";
|
import { detectDateOrder } from "./datetime";
|
||||||
import snapshot from "./rates-snapshot.json";
|
import snapshot from "./rates-snapshot.json";
|
||||||
import { CRYPTO } from "./extraunits";
|
import { CRYPTO } from "./extraunits";
|
||||||
|
|
||||||
const cryptoSnapshot: Record<string, number> = {};
|
const cryptoSnapshot: Record<string, number> = {};
|
||||||
for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd;
|
for (const c of CRYPTO) {cryptoSnapshot[c.code] = 1 / c.snapshotUsd;}
|
||||||
|
|
||||||
// "//" only starts a comment at line start or after whitespace, so URLs like
|
// "//" only starts a comment at line start or after whitespace, so URLs like
|
||||||
// "see https://example.com" aren't cut off.
|
// "see https://example.com" aren't cut off.
|
||||||
|
|
@ -74,7 +78,7 @@ export class SumEngine {
|
||||||
|
|
||||||
setHistoricalRates(date: string, rates: Record<string, number>): void {
|
setHistoricalRates(date: string, rates: Record<string, number>): void {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
for (const [k, v] of Object.entries(rates)) m.set(k.toUpperCase(), v);
|
for (const [k, v] of Object.entries(rates)) {m.set(k.toUpperCase(), v);}
|
||||||
this.historicalRates.set(date, m);
|
this.historicalRates.set(date, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +88,7 @@ export class SumEngine {
|
||||||
|
|
||||||
updateSettings(patch: Partial<EngineSettings>): void {
|
updateSettings(patch: Partial<EngineSettings>): void {
|
||||||
this.settings = { ...this.settings, ...patch };
|
this.settings = { ...this.settings, ...patch };
|
||||||
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat);
|
if (patch.dateFormat) {this.dateOrder = resolveDateOrder(this.settings.dateFormat);}
|
||||||
}
|
}
|
||||||
|
|
||||||
completions(): Completion[] {
|
completions(): Completion[] {
|
||||||
|
|
@ -148,8 +152,8 @@ export class SumEngine {
|
||||||
}
|
}
|
||||||
value = null;
|
value = null;
|
||||||
}
|
}
|
||||||
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
|
if (value?.kind === "quantity" && !value.value.isFinite()) {value = null;}
|
||||||
if (value && parsed.assign) vars.set(parsed.assign, value);
|
if (value && parsed.assign) {vars.set(parsed.assign, value);}
|
||||||
}
|
}
|
||||||
|
|
||||||
lineValues.push(value);
|
lineValues.push(value);
|
||||||
|
|
@ -182,7 +186,7 @@ export class SumEngine {
|
||||||
const qs = results
|
const qs = results
|
||||||
.map((r) => r.value)
|
.map((r) => r.value)
|
||||||
.filter((v): v is Quantity => v !== null && v.kind === "quantity");
|
.filter((v): v is Quantity => v !== null && v.kind === "quantity");
|
||||||
if (qs.length < 2) return null;
|
if (qs.length < 2) {return null;}
|
||||||
const anchor = qs.find((q) => q.unit) ?? qs[0];
|
const anchor = qs.find((q) => q.unit) ?? qs[0];
|
||||||
let acc = new Decimal(0);
|
let acc = new Decimal(0);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
@ -195,7 +199,7 @@ export class SumEngine {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count < 2) return null;
|
if (count < 2) {return null;}
|
||||||
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
|
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
|
||||||
return qty(value, anchor.unit ?? null);
|
return qty(value, anchor.unit ?? null);
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +218,7 @@ export class SumEngine {
|
||||||
|
|
||||||
addUnit(spec: ExtensionUnitSpec): void {
|
addUnit(spec: ExtensionUnitSpec): void {
|
||||||
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
|
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
|
||||||
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
|
if (!base) {throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);}
|
||||||
const unit: Unit = {
|
const unit: Unit = {
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
dimension: base.dimension,
|
dimension: base.dimension,
|
||||||
|
|
@ -225,7 +229,7 @@ export class SumEngine {
|
||||||
this.reg.unitsById.set(unit.id, unit);
|
this.reg.unitsById.set(unit.id, unit);
|
||||||
for (const p of spec.phrases.split(",")) {
|
for (const p of spec.phrases.split(",")) {
|
||||||
const phrase = p.trim();
|
const phrase = p.trim();
|
||||||
if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false });
|
if (phrase) {this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" });
|
this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" });
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +242,7 @@ export class SumEngine {
|
||||||
});
|
});
|
||||||
for (const p of (spec.phrases || spec.id).split(",")) {
|
for (const p of (spec.phrases || spec.id).split(",")) {
|
||||||
const phrase = p.trim();
|
const phrase = p.trim();
|
||||||
if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false });
|
if (phrase) {this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
this.reg.completions.push({ label: name, type: "function", detail: "extension" });
|
this.reg.completions.push({ label: name, type: "function", detail: "extension" });
|
||||||
}
|
}
|
||||||
|
|
@ -248,7 +252,7 @@ export class SumEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
private toValue(v: number | ExtensionValue): Value {
|
private toValue(v: number | ExtensionValue): Value {
|
||||||
if (typeof v === "number") return qty(v);
|
if (typeof v === "number") {return qty(v);}
|
||||||
const unit = v.unitId
|
const unit = v.unitId
|
||||||
? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null
|
? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -259,8 +263,8 @@ export class SumEngine {
|
||||||
if (v.kind === "quantity") {
|
if (v.kind === "quantity") {
|
||||||
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
|
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
|
||||||
}
|
}
|
||||||
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
|
if (v.kind === "percent") {return { double: v.value.div(100).toNumber() };}
|
||||||
if (v.kind === "date") return { double: v.ms };
|
if (v.kind === "date") {return { double: v.ms };}
|
||||||
return { double: 0 }; // chart — not representable as ExtensionValue
|
return { double: 0 }; // chart — not representable as ExtensionValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
|
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
|
||||||
// tokenizer and the phrase registry so both segment text the same way.
|
// tokenizer and the phrase registry so both segment text the same way.
|
||||||
import { DateOrder, Decimal, NumeralRepr } from "./types";
|
import type { DateOrder, NumeralRepr } from "./types";
|
||||||
|
import { Decimal } from "./types";
|
||||||
|
|
||||||
export interface Lex {
|
export interface Lex {
|
||||||
type: "num" | "word" | "sym" | "date" | "xref";
|
type: "num" | "word" | "sym" | "date" | "xref";
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
// Noise-tolerant Pratt parser. Unknown words are dropped (free text around
|
// Noise-tolerant Pratt parser. Unknown words are dropped (free text around
|
||||||
// math is allowed), except right after a conversion word — those may name
|
// math is allowed), except right after a conversion word — those may name
|
||||||
// a timezone ("time in New York").
|
// a timezone ("time in New York").
|
||||||
import { Decimal, NumeralRepr, Unit } from "./types";
|
import type { Decimal, NumeralRepr, Unit } from "./types";
|
||||||
import { Token } from "./tokenizer";
|
import type { Token } from "./tokenizer";
|
||||||
import { PctOp, DateWord, BitOp } from "./registry";
|
import type { PctOp, DateWord, BitOp } from "./registry";
|
||||||
|
|
||||||
export type ConvTarget =
|
export type ConvTarget =
|
||||||
| { type: "unit"; unit: Unit }
|
| { type: "unit"; unit: Unit }
|
||||||
|
|
@ -48,7 +48,7 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
|
||||||
let assign: string | undefined;
|
let assign: string | undefined;
|
||||||
// a defined variable shadows whatever its name tokenized as (unit "m", agg "sum"...)
|
// a defined variable shadows whatever its name tokenized as (unit "m", agg "sum"...)
|
||||||
let toks: Token[] = tokens.map((tk) => {
|
let toks: Token[] = tokens.map((tk) => {
|
||||||
if (tk.t === "word" || tk.t === "num" || tk.t === "junk") return tk;
|
if (tk.t === "word" || tk.t === "num" || tk.t === "junk") {return tk;}
|
||||||
const raw = line.slice(tk.start, tk.end);
|
const raw = line.slice(tk.start, tk.end);
|
||||||
return knownVars.has(raw) ? { t: "word", raw, start: tk.start, end: tk.end } : tk;
|
return knownVars.has(raw) ? { t: "word", raw, start: tk.start, end: tk.end } : tk;
|
||||||
});
|
});
|
||||||
|
|
@ -67,9 +67,9 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
|
||||||
const filtered: Token[] = [];
|
const filtered: Token[] = [];
|
||||||
let afterConv = false;
|
let afterConv = false;
|
||||||
for (const tk of toks) {
|
for (const tk of toks) {
|
||||||
if (tk.t === "junk") continue;
|
if (tk.t === "junk") {continue;}
|
||||||
if (tk.t === "word") {
|
if (tk.t === "word") {
|
||||||
if (knownVars.has(tk.raw) || afterConv) filtered.push(tk);
|
if (knownVars.has(tk.raw) || afterConv) {filtered.push(tk);}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// a bare "/" inside a timezone name ("Europe/Berlin") must not break the
|
// a bare "/" inside a timezone name ("Europe/Berlin") must not break the
|
||||||
|
|
@ -89,7 +89,7 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
|
||||||
if (assignIdx >= 0) {
|
if (assignIdx >= 0) {
|
||||||
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
|
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
|
||||||
const rhs = new Parser(filtered.slice(assignIdx + 1), 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 { expr: { k: "goalseek", lhs, rhs } };}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,10 +173,10 @@ class Parser {
|
||||||
}
|
}
|
||||||
const before = this.i;
|
const before = this.i;
|
||||||
const node = this.parseExpr(PREC.BIT);
|
const node = this.parseExpr(PREC.BIT);
|
||||||
if (node) items.push(node);
|
if (node) {items.push(node);}
|
||||||
if (this.i === before) this.i++; // skip unparsable token
|
if (this.i === before) {this.i++;} // skip unparsable token
|
||||||
}
|
}
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) {return null;}
|
||||||
return mk();
|
return mk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,61 +185,61 @@ class Parser {
|
||||||
*/
|
*/
|
||||||
private parseExpr(minPrec: number = 0): Node | null {
|
private parseExpr(minPrec: number = 0): Node | null {
|
||||||
let tk = this.peek();
|
let tk = this.peek();
|
||||||
if (!tk) return null;
|
if (!tk) {return null;}
|
||||||
|
|
||||||
let lhs: Node | null = null;
|
let lhs: Node | null = null;
|
||||||
|
|
||||||
if (tk.t === "op" && tk.op === "minus") {
|
if (tk.t === "op" && tk.op === "minus") {
|
||||||
this.i++;
|
this.i++;
|
||||||
const x = this.parseExpr(PREC.UNARY);
|
const x = this.parseExpr(PREC.UNARY);
|
||||||
if (!x) return null;
|
if (!x) {return null;}
|
||||||
lhs = { k: "neg", x };
|
lhs = { k: "neg", x };
|
||||||
} else if (tk.t === "op" && tk.op === "plus") {
|
} else if (tk.t === "op" && tk.op === "plus") {
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = this.parseExpr(PREC.UNARY);
|
lhs = this.parseExpr(PREC.UNARY);
|
||||||
if (!lhs) return null;
|
if (!lhs) {return null;}
|
||||||
} else {
|
} else {
|
||||||
lhs = this.parsePrimary();
|
lhs = this.parsePrimary();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lhs) return null;
|
if (!lhs) {return null;}
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
tk = this.peek();
|
tk = this.peek();
|
||||||
if (!tk) break;
|
if (!tk) {break;}
|
||||||
|
|
||||||
if (tk.t === "percent") {
|
if (tk.t === "percent") {
|
||||||
if (PREC.POSTFIX < minPrec) break;
|
if (PREC.POSTFIX < minPrec) {break;}
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = { k: "pct", x: lhs };
|
lhs = { k: "pct", x: lhs };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "bang") {
|
if (tk.t === "bang") {
|
||||||
if (PREC.POSTFIX < minPrec) break;
|
if (PREC.POSTFIX < minPrec) {break;}
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = { k: "fact", x: lhs };
|
lhs = { k: "fact", x: lhs };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "unit") {
|
if (tk.t === "unit") {
|
||||||
if (PREC.POSTFIX < minPrec) break;
|
if (PREC.POSTFIX < minPrec) {break;}
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = { k: "unit", x: lhs, unit: tk.unit };
|
lhs = { k: "unit", x: lhs, unit: tk.unit };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "currency") {
|
if (tk.t === "currency") {
|
||||||
if (PREC.POSTFIX < minPrec) break;
|
if (PREC.POSTFIX < minPrec) {break;}
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = { k: "curr", x: lhs, code: tk.code };
|
lhs = { k: "curr", x: lhs, code: tk.code };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "scale") {
|
if (tk.t === "scale") {
|
||||||
if (PREC.POSTFIX < minPrec) break;
|
if (PREC.POSTFIX < minPrec) {break;}
|
||||||
this.i++;
|
this.i++;
|
||||||
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
|
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "op" && tk.op === "pow") {
|
if (tk.t === "op" && tk.op === "pow") {
|
||||||
if (PREC.POW < minPrec) break;
|
if (PREC.POW < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.POW);
|
const r = this.parseExpr(PREC.POW);
|
||||||
|
|
@ -248,7 +248,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) {
|
if (tk.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) {
|
||||||
if (PREC.MUL < minPrec) break;
|
if (PREC.MUL < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
const r = this.parseExpr(PREC.MUL + 1);
|
||||||
|
|
@ -261,7 +261,7 @@ class Parser {
|
||||||
this.i++;
|
this.i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (PREC.MUL < minPrec) break;
|
if (PREC.MUL < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
const r = this.parseExpr(PREC.MUL + 1);
|
||||||
|
|
@ -270,7 +270,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw))) {
|
if (tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw))) {
|
||||||
if (PREC.MUL < minPrec) break;
|
if (PREC.MUL < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
const r = this.parseExpr(PREC.MUL + 1);
|
||||||
if (!r) { this.i = save; break; }
|
if (!r) { this.i = save; break; }
|
||||||
|
|
@ -278,7 +278,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "op" && (tk.op === "plus" || tk.op === "minus")) {
|
if (tk.t === "op" && (tk.op === "plus" || tk.op === "minus")) {
|
||||||
if (PREC.ADD < minPrec) break;
|
if (PREC.ADD < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.ADD + 1);
|
const r = this.parseExpr(PREC.ADD + 1);
|
||||||
|
|
@ -287,7 +287,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "bitop") {
|
if (tk.t === "bitop") {
|
||||||
if (PREC.BIT < minPrec) break;
|
if (PREC.BIT < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.BIT + 1);
|
const r = this.parseExpr(PREC.BIT + 1);
|
||||||
|
|
@ -296,7 +296,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "conv") {
|
if (tk.t === "conv") {
|
||||||
if (PREC.CONV < minPrec) break;
|
if (PREC.CONV < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const target = this.parseTarget();
|
const target = this.parseTarget();
|
||||||
|
|
@ -305,7 +305,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
|
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
|
||||||
if (PREC.CONV < minPrec) break;
|
if (PREC.CONV < minPrec) {break;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.ADD);
|
const r = this.parseExpr(PREC.ADD);
|
||||||
|
|
@ -320,9 +320,9 @@ class Parser {
|
||||||
}
|
}
|
||||||
/** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */
|
/** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */
|
||||||
private tryHistoricalDate(target: ConvTarget): ConvTarget {
|
private tryHistoricalDate(target: ConvTarget): ConvTarget {
|
||||||
if (target.type !== "currency") return target;
|
if (target.type !== "currency") {return target;}
|
||||||
const nx = this.peek();
|
const nx = this.peek();
|
||||||
if (nx?.t !== "pctop" || nx.op !== "on") return target;
|
if (nx?.t !== "pctop" || nx.op !== "on") {return target;}
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const dk = this.peek();
|
const dk = this.peek();
|
||||||
|
|
@ -338,7 +338,7 @@ class Parser {
|
||||||
|
|
||||||
private parseTarget(): ConvTarget | null {
|
private parseTarget(): ConvTarget | null {
|
||||||
const tk = this.peek();
|
const tk = this.peek();
|
||||||
if (!tk) return null;
|
if (!tk) {return null;}
|
||||||
if (tk.t === "unit") {
|
if (tk.t === "unit") {
|
||||||
this.i++;
|
this.i++;
|
||||||
return { type: "unit", unit: tk.unit };
|
return { type: "unit", unit: tk.unit };
|
||||||
|
|
@ -378,14 +378,14 @@ class Parser {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (words.length > 0) return { type: "tz", words };
|
if (words.length > 0) {return { type: "tz", words };}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePrimary(): Node | null {
|
private parsePrimary(): Node | null {
|
||||||
const tk = this.peek();
|
const tk = this.peek();
|
||||||
if (!tk) return null;
|
if (!tk) {return null;}
|
||||||
switch (tk.t) {
|
switch (tk.t) {
|
||||||
case "num":
|
case "num":
|
||||||
this.i++;
|
this.i++;
|
||||||
|
|
@ -419,7 +419,7 @@ class Parser {
|
||||||
const args: Node[] = [];
|
const args: Node[] = [];
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const arg = this.parseExpr(PREC.CONV);
|
const arg = this.parseExpr(PREC.CONV);
|
||||||
if (arg) args.push(arg);
|
if (arg) {args.push(arg);}
|
||||||
const nx = this.peek();
|
const nx = this.peek();
|
||||||
if (nx?.t === "semicolon") {
|
if (nx?.t === "semicolon") {
|
||||||
this.i++;
|
this.i++;
|
||||||
|
|
@ -429,7 +429,7 @@ class Parser {
|
||||||
this.i++;
|
this.i++;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!nx) break;
|
if (!nx) {break;}
|
||||||
if (!arg) {
|
if (!arg) {
|
||||||
this.i++; // skip stray token inside call
|
this.i++; // skip stray token inside call
|
||||||
}
|
}
|
||||||
|
|
@ -438,14 +438,14 @@ class Parser {
|
||||||
}
|
}
|
||||||
// `sqrt 16` / `square root of 16` — the "of" is decorative
|
// `sqrt 16` / `square root of 16` — the "of" is decorative
|
||||||
const nx = this.peek();
|
const nx = this.peek();
|
||||||
if (nx?.t === "pctop" && nx.op === "of") this.i++;
|
if (nx?.t === "pctop" && nx.op === "of") {this.i++;}
|
||||||
const arg = this.parseExpr(PREC.UNARY);
|
const arg = this.parseExpr(PREC.UNARY);
|
||||||
return arg ? { k: "call", name, args: [arg] } : null;
|
return arg ? { k: "call", name, args: [arg] } : null;
|
||||||
}
|
}
|
||||||
case "lparen": {
|
case "lparen": {
|
||||||
this.i++;
|
this.i++;
|
||||||
const inner = this.parseExpr(PREC.CONV);
|
const inner = this.parseExpr(PREC.CONV);
|
||||||
if (this.peek()?.t === "rparen") this.i++;
|
if (this.peek()?.t === "rparen") {this.i++;}
|
||||||
return inner;
|
return inner;
|
||||||
}
|
}
|
||||||
case "currency": {
|
case "currency": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { buildRegistry, Registry } from "./registry";
|
import type { Registry } from "./registry";
|
||||||
|
import { buildRegistry } from "./registry";
|
||||||
import { lexLine } from "./lexer";
|
import { lexLine } from "./lexer";
|
||||||
import { Decimal } from "./types";
|
import { Decimal } from "./types";
|
||||||
|
|
||||||
|
|
@ -16,12 +17,12 @@ describe("registry", () => {
|
||||||
const plusPayload = reg.match(lexLine("plus"), ["plus"], 0);
|
const plusPayload = reg.match(lexLine("plus"), ["plus"], 0);
|
||||||
expect(plusPayload).not.toBeNull();
|
expect(plusPayload).not.toBeNull();
|
||||||
expect(plusPayload?.payload.t).toBe("op");
|
expect(plusPayload?.payload.t).toBe("op");
|
||||||
if (plusPayload?.payload.t === "op") expect(plusPayload.payload.op).toBe("plus");
|
if (plusPayload?.payload.t === "op") {expect(plusPayload.payload.op).toBe("plus");}
|
||||||
|
|
||||||
const piPayload = reg.match(lexLine("pi"), ["pi"], 0);
|
const piPayload = reg.match(lexLine("pi"), ["pi"], 0);
|
||||||
expect(piPayload).not.toBeNull();
|
expect(piPayload).not.toBeNull();
|
||||||
expect(piPayload?.payload.t).toBe("const");
|
expect(piPayload?.payload.t).toBe("const");
|
||||||
if (piPayload?.payload.t === "const") expect(piPayload.payload.name).toBe("pi");
|
if (piPayload?.payload.t === "const") {expect(piPayload.payload.name).toBe("pi");}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers built-in functions", () => {
|
it("registers built-in functions", () => {
|
||||||
|
|
@ -90,14 +91,14 @@ describe("registry", () => {
|
||||||
const log2Match = reg.match(lex2, lex2.map(l => l.raw), 0);
|
const log2Match = reg.match(lex2, lex2.map(l => l.raw), 0);
|
||||||
expect(log2Match).not.toBeNull();
|
expect(log2Match).not.toBeNull();
|
||||||
expect(log2Match?.payload.t).toBe("func");
|
expect(log2Match?.payload.t).toBe("func");
|
||||||
if (log2Match?.payload.t === "func") expect(log2Match.payload.name).toBe("log2");
|
if (log2Match?.payload.t === "func") {expect(log2Match.payload.name).toBe("log2");}
|
||||||
|
|
||||||
const lexSpace2 = lexLine("log 2");
|
const lexSpace2 = lexLine("log 2");
|
||||||
const logSpace2Match = reg.match(lexSpace2, lexSpace2.map(l => l.raw), 0);
|
const logSpace2Match = reg.match(lexSpace2, lexSpace2.map(l => l.raw), 0);
|
||||||
// "log 2" should match "log", length 1, not "log2"
|
// "log 2" should match "log", length 1, not "log2"
|
||||||
expect(logSpace2Match).not.toBeNull();
|
expect(logSpace2Match).not.toBeNull();
|
||||||
expect(logSpace2Match?.length).toBe(1);
|
expect(logSpace2Match?.length).toBe(1);
|
||||||
if (logSpace2Match?.payload.t === "func") expect(logSpace2Match.payload.name).toBe("log");
|
if (logSpace2Match?.payload.t === "func") {expect(logSpace2Match.payload.name).toBe("log");}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for no match", () => {
|
it("returns null for no match", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
// currencies, word-operators, functions, scales, date words) to a payload
|
// currencies, word-operators, functions, scales, date words) to a payload
|
||||||
// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
|
// for the tokenizer. Longest phrase wins; case-sensitive entries (symbols
|
||||||
// like "m"/"M", "kB") are tried before the case-insensitive ones.
|
// like "m"/"M", "kB") are tried before the case-insensitive ones.
|
||||||
import { Decimal, Dimension, NumeralRepr, Unit, Value } from "./types";
|
import type { Dimension, NumeralRepr, Unit, Value } from "./types";
|
||||||
import { Lex, lexLine } from "./lexer";
|
import { Decimal } from "./types";
|
||||||
|
import type { Lex} from "./lexer";
|
||||||
|
import { lexLine } from "./lexer";
|
||||||
import * as vocab from "./vocab";
|
import * as vocab from "./vocab";
|
||||||
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
||||||
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
|
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
|
||||||
|
|
@ -75,7 +77,7 @@ export class Registry {
|
||||||
|
|
||||||
addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
|
addPhrase(phrase: string, payload: Payload, opts: { caseSensitive?: boolean } = {}): void {
|
||||||
const lexs = lexLine(phrase);
|
const lexs = lexLine(phrase);
|
||||||
if (lexs.length === 0) return;
|
if (lexs.length === 0) {return;}
|
||||||
const lexemes = lexs.map((l) => l.raw);
|
const lexemes = lexs.map((l) => l.raw);
|
||||||
const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2);
|
const caseSensitive = opts.caseSensitive ?? lexemes.every((l) => l.length <= 2);
|
||||||
const wordOrNum = (t: Lex["type"]) => t === "word" || t === "num";
|
const wordOrNum = (t: Lex["type"]) => t === "word" || t === "num";
|
||||||
|
|
@ -104,9 +106,9 @@ export class Registry {
|
||||||
*/
|
*/
|
||||||
match(lexes: Lex[], lowers: string[], i: number): { payload: Payload; length: number } | null {
|
match(lexes: Lex[], lowers: string[], i: number): { payload: Payload; length: number } | null {
|
||||||
const list = this.byFirst.get(lowers[i]);
|
const list = this.byFirst.get(lowers[i]);
|
||||||
if (!list) return null;
|
if (!list) {return null;}
|
||||||
for (const e of list) {
|
for (const e of list) {
|
||||||
if (i + e.lexemes.length > lexes.length) continue;
|
if (i + e.lexemes.length > lexes.length) {continue;}
|
||||||
let ok = true;
|
let ok = true;
|
||||||
for (let k = 0; k < e.lexemes.length; k++) {
|
for (let k = 0; k < e.lexemes.length; k++) {
|
||||||
if (e.caseSensitive ? lexes[i + k].raw !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) {
|
if (e.caseSensitive ? lexes[i + k].raw !== e.lexemes[k] : lowers[i + k] !== e.lower[k]) {
|
||||||
|
|
@ -118,7 +120,7 @@ export class Registry {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ok) return { payload: e.payload, length: e.lexemes.length };
|
if (ok) {return { payload: e.payload, length: e.lexemes.length };}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -127,14 +129,14 @@ export class Registry {
|
||||||
this.rates.clear();
|
this.rates.clear();
|
||||||
for (const [code, v] of Object.entries(rates)) {
|
for (const [code, v] of Object.entries(rates)) {
|
||||||
const d = new Decimal(v);
|
const d = new Decimal(v);
|
||||||
if (d.isFinite() && d.gt(0)) this.rates.set(code.toUpperCase(), d);
|
if (d.isFinite() && d.gt(0)) {this.rates.set(code.toUpperCase(), d);}
|
||||||
}
|
}
|
||||||
this.rates.set("USD", new Decimal(1));
|
this.rates.set("USD", new Decimal(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
makeCurrencyUnit(code: string): Unit | null {
|
makeCurrencyUnit(code: string): Unit | null {
|
||||||
const rate = this.rates.get(code);
|
const rate = this.rates.get(code);
|
||||||
if (!rate) return null;
|
if (!rate) {return null;}
|
||||||
return this.makeCurrencyUnitFromRate(code, rate);
|
return this.makeCurrencyUnitFromRate(code, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +156,7 @@ export class Registry {
|
||||||
|
|
||||||
function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { caseSensitive?: boolean } = {}): void {
|
function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { caseSensitive?: boolean } = {}): void {
|
||||||
reg.unitsById.set(unit.id, unit);
|
reg.unitsById.set(unit.id, unit);
|
||||||
for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts);
|
for (const p of phrases) {reg.addPhrase(p, { t: "unit", unit }, opts);}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
|
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
|
||||||
|
|
@ -180,27 +182,27 @@ function registerCoreVocab(reg: Registry): void {
|
||||||
["divide", { t: "op", op: "div" }],
|
["divide", { t: "op", op: "div" }],
|
||||||
];
|
];
|
||||||
for (const [key, payload] of ops) {
|
for (const [key, payload] of ops) {
|
||||||
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", `${key}.variants`)) {reg.addPhrase(v, payload, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
for (const v of vocab.variants("Operations", "conversion.variants")) reg.addPhrase(v, { t: "conv" }, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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", "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 });
|
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("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
|
|
||||||
|
|
@ -224,30 +226,30 @@ function registerCoreVocab(reg: Registry): void {
|
||||||
reg.addPhrase(v, { t: "repr", repr: "roman" }, { caseSensitive: false });
|
reg.addPhrase(v, { t: "repr", repr: "roman" }, { caseSensitive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const v of vocab.variants("Percent", "percent.variants")) reg.addPhrase(v, { t: "percent" });
|
for (const v of vocab.variants("Percent", "percent.variants")) {reg.addPhrase(v, { t: "percent" });}
|
||||||
const pctOps: Array<[string, PctOp]> = [
|
const pctOps: Array<[string, PctOp]> = [
|
||||||
["percent_of", "of"], ["percent_off", "off"], ["percent_on", "on"],
|
["percent_of", "of"], ["percent_off", "off"], ["percent_on", "on"],
|
||||||
["as_a_percent_of", "as_pct_of"], ["as_a_percent_off", "as_pct_off"], ["as_a_percent_on", "as_pct_on"],
|
["as_a_percent_of", "as_pct_of"], ["as_a_percent_off", "as_pct_off"], ["as_a_percent_on", "as_pct_on"],
|
||||||
["percent_of_what_is", "of_what_is"], ["percent_off_what_is", "off_what_is"], ["percent_on_what_is", "on_what_is"],
|
["percent_of_what_is", "of_what_is"], ["percent_off_what_is", "off_what_is"], ["percent_on_what_is", "on_what_is"],
|
||||||
];
|
];
|
||||||
for (const [key, op] of pctOps) {
|
for (const [key, op] of pctOps) {
|
||||||
for (const v of vocab.variants("Percent", `${key}.variants`)) reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false });
|
for (const v of vocab.variants("Percent", `${key}.variants`)) {reg.addPhrase(v, { t: "pctop", op }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateWords: Array<[string, DateWord]> = [
|
const dateWords: Array<[string, DateWord]> = [
|
||||||
["today", "today"], ["tomorrow", "tomorrow"], ["yesterday", "yesterday"],
|
["today", "today"], ["tomorrow", "tomorrow"], ["yesterday", "yesterday"],
|
||||||
];
|
];
|
||||||
for (const [key, word] of dateWords) {
|
for (const [key, word] of dateWords) {
|
||||||
for (const v of vocab.variants("Dates", `${key}.variants`)) reg.addPhrase(v, { t: "date", word }, { caseSensitive: false });
|
for (const v of vocab.variants("Dates", `${key}.variants`)) {reg.addPhrase(v, { t: "date", word }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
for (const v of vocab.variants("Dates", "current_time.variants")) reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false });
|
for (const v of vocab.variants("Dates", "current_time.variants")) {reg.addPhrase(v, { t: "date", word: v.toLowerCase() === "now" || v.toLowerCase() === "сейчас" ? "now" : "time" }, { caseSensitive: false });}
|
||||||
for (const v of vocab.variants("Dates", "local_time.variants")) reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false });
|
for (const v of vocab.variants("Dates", "local_time.variants")) {reg.addPhrase(v, { t: "date", word: "local" }, { caseSensitive: false });}
|
||||||
|
|
||||||
const reprs: Array<[string, NumeralRepr]> = [
|
const reprs: Array<[string, NumeralRepr]> = [
|
||||||
["hex", "hex"], ["binary", "binary"], ["octal", "octal"], ["decimal", "decimal"], ["scientific", "scientific"],
|
["hex", "hex"], ["binary", "binary"], ["octal", "octal"], ["decimal", "decimal"], ["scientific", "scientific"],
|
||||||
];
|
];
|
||||||
for (const [key, repr] of reprs) {
|
for (const [key, repr] of reprs) {
|
||||||
for (const v of vocab.variants("Numbers", `${key}.variants`)) reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false });
|
for (const v of vocab.variants("Numbers", `${key}.variants`)) {reg.addPhrase(v, { t: "repr", repr }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [id, mult] of Object.entries(SCALE_DATA)) {
|
for (const [id, mult] of Object.entries(SCALE_DATA)) {
|
||||||
|
|
@ -267,7 +269,7 @@ function registerCoreVocab(reg: Registry): void {
|
||||||
reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false });
|
reg.addPhrase("полтора", { t: "const", name: "onehalf" }, { caseSensitive: false });
|
||||||
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
|
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
|
||||||
|
|
||||||
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
|
for (const f of BUILTIN_FUNCS) {reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UNIT_DATA + SI/IEC prefixes; returns the length-dimension phrases the
|
// UNIT_DATA + SI/IEC prefixes; returns the length-dimension phrases the
|
||||||
|
|
@ -291,7 +293,7 @@ function registerUnits(reg: Registry): LengthPhrase[] {
|
||||||
};
|
};
|
||||||
addUnitPhrases(reg, variants, unit);
|
addUnitPhrases(reg, variants, unit);
|
||||||
if (d.dimension === "length") {
|
if (d.dimension === "length") {
|
||||||
for (const v of variants) lengthPhrases.push({ phrase: v, unit });
|
for (const v of variants) {lengthPhrases.push({ phrase: v, unit });}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (d.prefixes) {
|
if (d.prefixes) {
|
||||||
|
|
@ -320,34 +322,34 @@ function registerUnits(reg: Registry): LengthPhrase[] {
|
||||||
csPhrases.push(`${pSym}bit`);
|
csPhrases.push(`${pSym}bit`);
|
||||||
} else {
|
} else {
|
||||||
// symbol compositions in every locale: "km" and "км"
|
// symbol compositions in every locale: "km" and "км"
|
||||||
for (const ps of pSymsAll) for (const us of symbolsAll) csPhrases.push(ps + us);
|
for (const ps of pSymsAll) {for (const us of symbolsAll) {csPhrases.push(ps + us);}}
|
||||||
}
|
}
|
||||||
for (const pw of pWords) for (const wv of wordVariants) phrases.push(pw + wv);
|
for (const pw of pWords) {for (const wv of wordVariants) {phrases.push(pw + wv);}}
|
||||||
if (d.id === "byte" && pSym) {
|
if (d.id === "byte" && pSym) {
|
||||||
// lenient: KB/kb/Kb all mean kilobyte
|
// lenient: KB/kb/Kb all mean kilobyte
|
||||||
phrases.push((pSym + "b").toLowerCase());
|
phrases.push((pSym + "b").toLowerCase());
|
||||||
}
|
}
|
||||||
reg.unitsById.set(pUnit.id, pUnit);
|
reg.unitsById.set(pUnit.id, pUnit);
|
||||||
for (const ph of csPhrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });
|
for (const ph of csPhrases) {reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: true });}
|
||||||
for (const ph of phrases) reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false });
|
for (const ph of phrases) {reg.addPhrase(ph, { t: "unit", unit: pUnit }, { caseSensitive: false });}
|
||||||
// collect unambiguous lowercase forms: "KM"/"Km" should still mean km,
|
// collect unambiguous lowercase forms: "KM"/"Km" should still mean km,
|
||||||
// while "mm"/"Mm" stay strict (milli vs mega)
|
// while "mm"/"Mm" stay strict (milli vs mega)
|
||||||
for (const ph of csPhrases) {
|
for (const ph of csPhrases) {
|
||||||
const lower = ph.toLowerCase();
|
const lower = ph.toLowerCase();
|
||||||
const prev = lenientSym.get(lower);
|
const prev = lenientSym.get(lower);
|
||||||
if (prev === undefined) lenientSym.set(lower, pUnit);
|
if (prev === undefined) {lenientSym.set(lower, pUnit);}
|
||||||
else if (prev && prev.id !== pUnit.id) lenientSym.set(lower, null);
|
else if (prev && prev.id !== pUnit.id) {lenientSym.set(lower, null);}
|
||||||
}
|
}
|
||||||
if (d.dimension === "length") {
|
if (d.dimension === "length") {
|
||||||
for (const ph of csPhrases) lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });
|
for (const ph of csPhrases) {lengthPhrases.push({ phrase: ph, unit: pUnit, caseSensitive: true });}
|
||||||
for (const pw of pWords) for (const wv of wordVariants) lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false });
|
for (const pw of pWords) {for (const wv of wordVariants) {lengthPhrases.push({ phrase: pw + wv, unit: pUnit, caseSensitive: false });}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [lower, unit] of lenientSym) {
|
for (const [lower, unit] of lenientSym) {
|
||||||
if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });
|
if (unit) {reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lengthPhrases;
|
return lengthPhrases;
|
||||||
|
|
@ -400,10 +402,10 @@ function registerCurrencies(reg: Registry): void {
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
reg.currencyCodes.add(code);
|
reg.currencyCodes.add(code);
|
||||||
const fmt = vocab.entryEn("Currency", `${code}.format`);
|
const fmt = vocab.entryEn("Currency", `${code}.format`);
|
||||||
if (fmt) reg.currencyFormat.set(code, fmt.replace("%@", "{}"));
|
if (fmt) {reg.currencyFormat.set(code, fmt.replace("%@", "{}"));}
|
||||||
const variants = vocab.variants("Currency", `${code}.variants`);
|
const variants = vocab.variants("Currency", `${code}.variants`);
|
||||||
if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code);
|
if (!variants.some((v) => v.toUpperCase() === code)) {variants.push(code);}
|
||||||
for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });
|
for (const v of variants) {reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,12 +422,12 @@ function registerExtraUnits(reg: Registry): void {
|
||||||
reg.unitsById.set(unit.id, unit);
|
reg.unitsById.set(unit.id, unit);
|
||||||
for (const p of d.phrases.split(",")) {
|
for (const p of d.phrases.split(",")) {
|
||||||
const ph = p.trim();
|
const ph = p.trim();
|
||||||
if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: false });
|
if (ph) {reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
if (d.symbols) {
|
if (d.symbols) {
|
||||||
for (const s of d.symbols.split(",")) {
|
for (const s of d.symbols.split(",")) {
|
||||||
const ph = s.trim();
|
const ph = s.trim();
|
||||||
if (ph) reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: true });
|
if (ph) {reg.addPhrase(ph, { t: "unit", unit }, { caseSensitive: true });}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +443,7 @@ function registerCrypto(reg: Registry): void {
|
||||||
reg.addPhrase(c.code, { t: "currency", code: c.code }, { caseSensitive: CRYPTO_STRICT_CODES.has(c.code) });
|
reg.addPhrase(c.code, { t: "currency", code: c.code }, { caseSensitive: CRYPTO_STRICT_CODES.has(c.code) });
|
||||||
for (const p of c.phrases.split(",")) {
|
for (const p of c.phrases.split(",")) {
|
||||||
const ph = p.trim();
|
const ph = p.trim();
|
||||||
if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });
|
if (ph) {reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -450,18 +452,18 @@ function registerCompletions(reg: Registry): void {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
|
const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
|
||||||
const k = `${type}:${label.toLowerCase()}`;
|
const k = `${type}:${label.toLowerCase()}`;
|
||||||
if (seen.has(k) || label.length < 2 || !/^[\p{L}]/u.test(label)) return;
|
if (seen.has(k) || label.length < 2 || !/^[\p{L}]/u.test(label)) {return;}
|
||||||
seen.add(k);
|
seen.add(k);
|
||||||
reg.completions.push({ label, type, detail });
|
reg.completions.push({ label, type, detail });
|
||||||
};
|
};
|
||||||
for (const d of UNIT_DATA) {
|
for (const d of UNIT_DATA) {
|
||||||
for (const v of vocab.variants(d.category, `${d.id}.variants`)) addCompletion(v, "unit", d.dimension);
|
for (const v of vocab.variants(d.category, `${d.id}.variants`)) {addCompletion(v, "unit", d.dimension);}
|
||||||
}
|
}
|
||||||
for (const d of EXTRA_UNITS) {
|
for (const d of EXTRA_UNITS) {
|
||||||
for (const v of d.phrases.split(",")) addCompletion(v.trim(), "unit", d.dimension);
|
for (const v of d.phrases.split(",")) {addCompletion(v.trim(), "unit", d.dimension);}
|
||||||
}
|
}
|
||||||
for (const code of reg.currencyCodes) addCompletion(code, "currency");
|
for (const code of reg.currencyCodes) {addCompletion(code, "currency");}
|
||||||
for (const f of BUILTIN_FUNCS) addCompletion(f, "function");
|
for (const f of BUILTIN_FUNCS) {addCompletion(f, "function");}
|
||||||
for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
|
for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
|
||||||
addCompletion(k, "keyword");
|
addCompletion(k, "keyword");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
import { tokenize } from "./tokenizer";
|
import { tokenize } from "./tokenizer";
|
||||||
import { buildRegistry, Registry } from "./registry";
|
import type { Registry } from "./registry";
|
||||||
|
import { buildRegistry } from "./registry";
|
||||||
|
|
||||||
describe("tokenizer", () => {
|
describe("tokenizer", () => {
|
||||||
let reg: Registry;
|
let reg: Registry;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Lexes a line and resolves phrases through the registry into semantic tokens.
|
// Lexes a line and resolves phrases through the registry into semantic tokens.
|
||||||
import { DateOrder, Decimal, NumeralRepr, Unit } from "./types";
|
import type { DateOrder, Decimal, NumeralRepr, Unit } from "./types";
|
||||||
import { lexLine } from "./lexer";
|
import { lexLine } from "./lexer";
|
||||||
import { Registry, PctOp, DateWord, BitOp } from "./registry";
|
import type { Registry, PctOp, DateWord, BitOp } from "./registry";
|
||||||
|
|
||||||
export type Token =
|
export type Token =
|
||||||
| { t: "num"; v: Decimal; repr: NumeralRepr; start: number; end: number }
|
| { t: "num"; v: Decimal; repr: NumeralRepr; start: number; end: number }
|
||||||
|
|
@ -125,15 +125,15 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
|
||||||
*/
|
*/
|
||||||
function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
|
function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
|
||||||
const inch = reg.unitsById.get("inch");
|
const inch = reg.unitsById.get("inch");
|
||||||
if (!inch) return tokens;
|
if (!inch) {return tokens;}
|
||||||
return tokens.map((tk, i) => {
|
return tokens.map((tk, i) => {
|
||||||
if (tk.t !== "conv" || line.slice(tk.start, tk.end).toLowerCase() !== "in") return tk;
|
if (tk.t !== "conv" || line.slice(tk.start, tk.end).toLowerCase() !== "in") {return tk;}
|
||||||
const prev = tokens[i - 1];
|
const prev = tokens[i - 1];
|
||||||
const next = tokens[i + 1];
|
const next = tokens[i + 1];
|
||||||
const prevIsNum = prev?.t === "num";
|
const prevIsNum = prev?.t === "num";
|
||||||
const nextEnds = !next || next.t === "conv" || next.t === "op" || next.t === "rparen";
|
const nextEnds = !next || next.t === "conv" || next.t === "op" || next.t === "rparen";
|
||||||
if (prevIsNum && nextEnds) {
|
if (prevIsNum && nextEnds) {
|
||||||
return { t: "unit", unit: inch, start: tk.start, end: tk.end } as Token;
|
return { t: "unit", unit: inch, start: tk.start, end: tk.end };
|
||||||
}
|
}
|
||||||
return tk;
|
return tk;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// Conversion ratios. Base units per dimension: length=meter, area=m²,
|
// Conversion ratios. Base units per dimension: length=meter, area=m²,
|
||||||
// volume=m³, weight=gram, time=second, angle=radian, data=bit,
|
// volume=m³, weight=gram, time=second, angle=radian, data=bit,
|
||||||
// temperature=kelvin (ratio+offset).
|
// temperature=kelvin (ratio+offset).
|
||||||
import { Decimal, Dimension } from "./types";
|
import type { Dimension } from "./types";
|
||||||
|
import { Decimal } from "./types";
|
||||||
|
|
||||||
export interface UnitData {
|
export interface UnitData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ export function variants(category: string, key: string): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const loc of locales) {
|
for (const loc of locales) {
|
||||||
const raw = loc[category]?.[key];
|
const raw = loc[category]?.[key];
|
||||||
if (!raw) continue;
|
if (!raw) {continue;}
|
||||||
for (const v of raw.split(",")) {
|
for (const v of raw.split(",")) {
|
||||||
const t = v.trim();
|
const t = v.trim();
|
||||||
if (t && !out.includes(t)) out.push(t);
|
if (t && !out.includes(t)) {out.push(t);}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|
@ -25,7 +25,7 @@ export function variants(category: string, key: string): string[] {
|
||||||
export function entry(category: string, key: string): string | undefined {
|
export function entry(category: string, key: string): string | undefined {
|
||||||
for (const loc of locales) {
|
for (const loc of locales) {
|
||||||
const raw = loc[category]?.[key];
|
const raw = loc[category]?.[key];
|
||||||
if (raw !== undefined) return raw;
|
if (raw !== undefined) {return raw;}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ export function entriesAll(category: string, key: string): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const loc of locales) {
|
for (const loc of locales) {
|
||||||
const raw = loc[category]?.[key];
|
const raw = loc[category]?.[key];
|
||||||
if (raw && !out.includes(raw)) out.push(raw);
|
if (raw && !out.includes(raw)) {out.push(raw);}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -50,9 +50,9 @@ export function idsOf(category: string): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const loc of locales) {
|
for (const loc of locales) {
|
||||||
const cat = loc[category];
|
const cat = loc[category];
|
||||||
if (!cat) continue;
|
if (!cat) {continue;}
|
||||||
for (const k of Object.keys(cat)) {
|
for (const k of Object.keys(cat)) {
|
||||||
if (k.endsWith(".variants")) ids.add(k.slice(0, -".variants".length));
|
if (k.endsWith(".variants")) {ids.add(k.slice(0, -".variants".length));}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
|
|
@ -63,9 +63,9 @@ export function idsWithSuffix(category: string, suffix: string): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const loc of locales) {
|
for (const loc of locales) {
|
||||||
const cat = loc[category];
|
const cat = loc[category];
|
||||||
if (!cat) continue;
|
if (!cat) {continue;}
|
||||||
for (const k of Object.keys(cat)) {
|
for (const k of Object.keys(cat)) {
|
||||||
if (k.endsWith(suffix)) ids.add(k.slice(0, -suffix.length));
|
if (k.endsWith(suffix)) {ids.add(k.slice(0, -suffix.length));}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { makeApi, runExtensions } from "./extensions";
|
import { makeApi, runExtensions } from "./extensions";
|
||||||
import { SumEngine } from "./engine";
|
import type { SumEngine } from "./engine";
|
||||||
|
|
||||||
describe("extensions", () => {
|
describe("extensions", () => {
|
||||||
let mockEngine: any;
|
let mockEngine: any;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||||
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
||||||
import {
|
import type {
|
||||||
newQuickJSWASMModuleFromVariant,
|
|
||||||
QuickJSContext,
|
QuickJSContext,
|
||||||
QuickJSHandle,
|
QuickJSHandle} from "quickjs-emscripten-core";
|
||||||
|
import {
|
||||||
|
newQuickJSWASMModuleFromVariant
|
||||||
} from "quickjs-emscripten-core";
|
} from "quickjs-emscripten-core";
|
||||||
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
|
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
|
||||||
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
import type { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
||||||
|
|
||||||
interface ExtensionApi {
|
interface ExtensionApi {
|
||||||
setVariable(name: string, value: number | ExtensionValue): void;
|
setVariable(name: string, value: number | ExtensionValue): void;
|
||||||
|
|
@ -40,7 +41,7 @@ const now = () =>
|
||||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||||
|
|
||||||
function toExtValue(v: unknown, what: string): ExtensionValue | number {
|
function toExtValue(v: unknown, what: string): ExtensionValue | number {
|
||||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
if (typeof v === "number" && Number.isFinite(v)) {return v;}
|
||||||
if (v && typeof v === "object") {
|
if (v && typeof v === "object") {
|
||||||
const o = v as { double?: unknown; unitId?: unknown };
|
const o = v as { double?: unknown; unitId?: unknown };
|
||||||
if (typeof o.double === "number" && Number.isFinite(o.double)) {
|
if (typeof o.double === "number" && Number.isFinite(o.double)) {
|
||||||
|
|
@ -53,7 +54,7 @@ function toExtValue(v: unknown, what: string): ExtensionValue | number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function message(e: unknown): string {
|
function message(e: unknown): string {
|
||||||
if (e instanceof Error) return e.message;
|
if (e instanceof Error) {return e.message;}
|
||||||
if (
|
if (
|
||||||
e &&
|
e &&
|
||||||
typeof e === "object" &&
|
typeof e === "object" &&
|
||||||
|
|
@ -71,7 +72,7 @@ export async function runExtensions(
|
||||||
engine: SumEngine,
|
engine: SumEngine,
|
||||||
scripts: { name: string; code: string }[],
|
scripts: { name: string; code: string }[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (scripts.length === 0) return;
|
if (scripts.length === 0) {return;}
|
||||||
try {
|
try {
|
||||||
await load(engine, scripts);
|
await load(engine, scripts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -141,7 +142,7 @@ function exposeApi(
|
||||||
define("setVariable", (nameHandle, valueHandle) => {
|
define("setVariable", (nameHandle, valueHandle) => {
|
||||||
const name = vm.dump(nameHandle);
|
const name = vm.dump(nameHandle);
|
||||||
if (typeof name !== "string" || !name)
|
if (typeof name !== "string" || !name)
|
||||||
throw new Error("expects a variable name");
|
{throw new Error("expects a variable name");}
|
||||||
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
|
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -167,9 +168,9 @@ function exposeApi(
|
||||||
define("addFunction", (specHandle, fnHandle) => {
|
define("addFunction", (specHandle, fnHandle) => {
|
||||||
const spec = vm.dump(specHandle);
|
const spec = vm.dump(specHandle);
|
||||||
if (!spec || typeof spec.id !== "string")
|
if (!spec || typeof spec.id !== "string")
|
||||||
throw new Error("expects { id, phrases }");
|
{throw new Error("expects { id, phrases }");}
|
||||||
if (!fnHandle || vm.typeof(fnHandle) !== "function")
|
if (!fnHandle || vm.typeof(fnHandle) !== "function")
|
||||||
throw new Error("expects a function");
|
{throw new Error("expects a function");}
|
||||||
// outlives this callback, so it needs its own reference; the context is never disposed
|
// outlives this callback, so it needs its own reference; the context is never disposed
|
||||||
const guestFn = fnHandle.dup();
|
const guestFn = fnHandle.dup();
|
||||||
const id = spec.id;
|
const id = spec.id;
|
||||||
|
|
|
||||||
138
src/main.ts
138
src/main.ts
|
|
@ -2,8 +2,9 @@
|
||||||
import "./ui/app.css";
|
import "./ui/app.css";
|
||||||
import { SumEngine } from "./engine";
|
import { SumEngine } from "./engine";
|
||||||
import { SumEditor } from "./ui/editor";
|
import { SumEditor } from "./ui/editor";
|
||||||
import {
|
import type {
|
||||||
AppData, DocMeta, SettingsData, defaultSettingsData,
|
AppData, DocMeta, SettingsData} from "./storage";
|
||||||
|
import { defaultSettingsData,
|
||||||
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings,
|
||||||
fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri,
|
fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri,
|
||||||
getLaunchFile, onOpenFile, onFileDrop,
|
getLaunchFile, onOpenFile, onFileDrop,
|
||||||
|
|
@ -17,7 +18,8 @@ import { checkForUpdate } from "./updater";
|
||||||
import { Workspace } from "./workspace";
|
import { Workspace } from "./workspace";
|
||||||
import { EN, RU } from "./engine/vocab-data";
|
import { EN, RU } from "./engine/vocab-data";
|
||||||
import { exportSheetImage } from "./ui/export";
|
import { exportSheetImage } from "./ui/export";
|
||||||
import { initSearch, SearchController } from "./ui/search";
|
import type { SearchController } from "./ui/search";
|
||||||
|
import { initSearch } from "./ui/search";
|
||||||
import pkg from "../package.json";
|
import pkg from "../package.json";
|
||||||
|
|
||||||
function welcomeText(lang: string): string {
|
function welcomeText(lang: string): string {
|
||||||
|
|
@ -53,9 +55,9 @@ function titleFromContent(text: string): string {
|
||||||
let start = 0;
|
let start = 0;
|
||||||
while (start < text.length) {
|
while (start < text.length) {
|
||||||
let end = text.indexOf("\n", start);
|
let end = text.indexOf("\n", start);
|
||||||
if (end === -1) end = text.length;
|
if (end === -1) {end = text.length;}
|
||||||
const s = text.slice(start, end).trim();
|
const s = text.slice(start, end).trim();
|
||||||
if (s.length > 0) return s.replace(/^#\s*/, "").slice(0, 30);
|
if (s.length > 0) {return s.replace(/^#\s*/, "").slice(0, 30);}
|
||||||
start = end + 1;
|
start = end + 1;
|
||||||
}
|
}
|
||||||
return t("untitled");
|
return t("untitled");
|
||||||
|
|
@ -63,12 +65,12 @@ function titleFromContent(text: string): string {
|
||||||
|
|
||||||
function syncTitleField(): void {
|
function syncTitleField(): void {
|
||||||
const field = $<HTMLInputElement>("#doc-title");
|
const field = $<HTMLInputElement>("#doc-title");
|
||||||
if (document.activeElement !== field) field.value = activeDoc()?.title ?? "";
|
if (document.activeElement !== field) {field.value = activeDoc()?.title ?? "";}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pinDoc(id: string): void {
|
function pinDoc(id: string): void {
|
||||||
const doc = data.docs.find((d) => d.id === id);
|
const doc = data.docs.find((d) => d.id === id);
|
||||||
if (!doc) return;
|
if (!doc) {return;}
|
||||||
doc.pinned = !doc.pinned;
|
doc.pinned = !doc.pinned;
|
||||||
if (doc.pinned) {
|
if (doc.pinned) {
|
||||||
// move to end of pinned group
|
// move to end of pinned group
|
||||||
|
|
@ -88,10 +90,10 @@ function pinDoc(id: string): void {
|
||||||
|
|
||||||
/** drag id -> drop-target id; no-op across the pinned/unpinned boundary */
|
/** drag id -> drop-target id; no-op across the pinned/unpinned boundary */
|
||||||
function reorderDoc(srcId: string, targetId: string): void {
|
function reorderDoc(srcId: string, targetId: string): void {
|
||||||
if (srcId === targetId) return;
|
if (srcId === targetId) {return;}
|
||||||
const src = data.docs.find((d) => d.id === srcId);
|
const src = data.docs.find((d) => d.id === srcId);
|
||||||
const target = data.docs.find((d) => d.id === targetId);
|
const target = data.docs.find((d) => d.id === targetId);
|
||||||
if (!src || !target || !!src.pinned !== !!target.pinned) return;
|
if (!src || !target || !!src.pinned !== !!target.pinned) {return;}
|
||||||
data.docs = data.docs.filter((d) => d.id !== srcId);
|
data.docs = data.docs.filter((d) => d.id !== srcId);
|
||||||
const targetIdx = data.docs.findIndex((d) => d.id === targetId);
|
const targetIdx = data.docs.findIndex((d) => d.id === targetId);
|
||||||
data.docs.splice(targetIdx, 0, src);
|
data.docs.splice(targetIdx, 0, src);
|
||||||
|
|
@ -108,12 +110,12 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
let suppressClick = false;
|
let suppressClick = false;
|
||||||
el.addEventListener("mousedown", (e) => {
|
el.addEventListener("mousedown", (e) => {
|
||||||
if ((e.target as HTMLElement).closest("button")) return;
|
if ((e.target as HTMLElement).closest("button")) {return;}
|
||||||
const startY = e.clientY;
|
const startY = e.clientY;
|
||||||
let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null;
|
let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null;
|
||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
if (!dragging) {
|
if (!dragging) {
|
||||||
if (Math.abs(ev.clientY - startY) < 4) return;
|
if (Math.abs(ev.clientY - startY) < 4) {return;}
|
||||||
dragging = true;
|
dragging = true;
|
||||||
suppressClick = true;
|
suppressClick = true;
|
||||||
el.classList.add("dragging");
|
el.classList.add("dragging");
|
||||||
|
|
@ -137,7 +139,7 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
||||||
}
|
}
|
||||||
if (over) {
|
if (over) {
|
||||||
const overDoc = data.docs.find((d) => d.id === over.dataset.docId);
|
const overDoc = data.docs.find((d) => d.id === over.dataset.docId);
|
||||||
if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over");
|
if (overDoc && !!overDoc.pinned === !!doc.pinned) {over.classList.add("drag-over");}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onScroll = () => { cachedItems = null; };
|
const onScroll = () => { cachedItems = null; };
|
||||||
|
|
@ -148,9 +150,9 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement, d
|
||||||
cachedItems = null;
|
cachedItems = null;
|
||||||
if (dragging) {
|
if (dragging) {
|
||||||
const target = list.querySelector<HTMLElement>(".doc-item.drag-over");
|
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 = "";
|
document.body.style.cursor = "";
|
||||||
if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId);
|
if (target?.dataset.docId) {reorderDoc(doc.id, target.dataset.docId);}
|
||||||
}
|
}
|
||||||
dragging = false;
|
dragging = false;
|
||||||
};
|
};
|
||||||
|
|
@ -181,7 +183,7 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting
|
||||||
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
del.addEventListener("click", (e) => {
|
del.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (appData.docs.length === 1) return;
|
if (appData.docs.length === 1) {return;}
|
||||||
if (!del.classList.contains("confirm")) {
|
if (!del.classList.contains("confirm")) {
|
||||||
del.classList.add("confirm");
|
del.classList.add("confirm");
|
||||||
del.textContent = "✓";
|
del.textContent = "✓";
|
||||||
|
|
@ -191,12 +193,12 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (confirmTimer) clearTimeout(confirmTimer);
|
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];
|
delete appData.contents[doc.id];
|
||||||
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
|
appData.docs = appData.docs.filter((d) => d.id !== doc.id);
|
||||||
if (appData.activeId === doc.id) switchDoc(appData.docs[0].id);
|
if (appData.activeId === doc.id) {switchDoc(appData.docs[0].id);}
|
||||||
else renderDocList();
|
else {renderDocList();}
|
||||||
saveAppData(appData);
|
saveAppData(appData);
|
||||||
});
|
});
|
||||||
return del;
|
return del;
|
||||||
|
|
@ -244,13 +246,13 @@ function newDoc(content = ""): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeActiveDoc(): Promise<void> {
|
async function closeActiveDoc(): Promise<void> {
|
||||||
if (data.docs.length <= 1) return;
|
if (data.docs.length <= 1) {return;}
|
||||||
const doc = data.docs.find((d) => d.id === data.activeId);
|
const doc = data.docs.find((d) => d.id === data.activeId);
|
||||||
if (!doc) return;
|
if (!doc) {return;}
|
||||||
const content = data.contents[doc.id] ?? "";
|
const content = data.contents[doc.id] ?? "";
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
const ans = await askModal(t("closeSheet"), t("close"), t("cancel"));
|
const ans = await askModal(t("closeSheet"), t("close"), t("cancel"));
|
||||||
if (ans !== "a") return;
|
if (ans !== "a") {return;}
|
||||||
}
|
}
|
||||||
const idx = data.docs.findIndex((d) => d.id === doc.id);
|
const idx = data.docs.findIndex((d) => d.id === doc.id);
|
||||||
void backupDeletedSheet(settings.dataDir, doc.title, content);
|
void backupDeletedSheet(settings.dataDir, doc.title, content);
|
||||||
|
|
@ -328,7 +330,7 @@ function bindFormattingSettings(): void {
|
||||||
resultsWidth.addEventListener("input", save); // live while sliding
|
resultsWidth.addEventListener("input", save); // live while sliding
|
||||||
|
|
||||||
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||||
if (settings.theme === "system") applySettings();
|
if (settings.theme === "system") {applySettings();}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,9 +363,9 @@ function bindHotkeySettings(): void {
|
||||||
hotkey.value = mods.length ? mods.join("+") + "+…" : "";
|
hotkey.value = mods.length ? mods.join("+") + "+…" : "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mods.length === 0) return; // a bare key is not a global hotkey
|
if (mods.length === 0) {return;} // a bare key is not a global hotkey
|
||||||
const keyName = normalizeKeyName(e);
|
const keyName = normalizeKeyName(e);
|
||||||
if (!keyName) return;
|
if (!keyName) {return;}
|
||||||
const combo = [...mods, keyName].join("+");
|
const combo = [...mods, keyName].join("+");
|
||||||
const old = settings.hotkey;
|
const old = settings.hotkey;
|
||||||
if (await registerHotkey(old, combo)) {
|
if (await registerHotkey(old, combo)) {
|
||||||
|
|
@ -423,11 +425,11 @@ function bindDataDirSettings(): void {
|
||||||
renderDataDir();
|
renderDataDir();
|
||||||
dataDirBtn.addEventListener("click", async () => {
|
dataDirBtn.addEventListener("click", async () => {
|
||||||
const picked = await chooseFolder();
|
const picked = await chooseFolder();
|
||||||
if (!picked || picked === settings.dataDir) return;
|
if (!picked || picked === settings.dataDir) {return;}
|
||||||
let strategy: "move" | "overwrite" | "use_existing" = "move";
|
let strategy: "move" | "overwrite" | "use_existing" = "move";
|
||||||
if (await dataDirHasDocuments(picked)) {
|
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;
|
if (ans === null) {return;}
|
||||||
strategy = ans === "a" ? "use_existing" : "overwrite";
|
strategy = ans === "a" ? "use_existing" : "overwrite";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -445,7 +447,7 @@ function bindDataDirSettings(): void {
|
||||||
const fresh = await loadAppData(picked);
|
const fresh = await loadAppData(picked);
|
||||||
if (fresh && fresh.docs.length > 0) {
|
if (fresh && fresh.docs.length > 0) {
|
||||||
data = fresh;
|
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);
|
switchDoc(data.activeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -458,8 +460,8 @@ function bindSettingsNavigation(): void {
|
||||||
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
for (const other of tabs) other.classList.toggle("active", other === tab);
|
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);}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,10 +497,10 @@ async function showAppVersion(): Promise<void> {
|
||||||
/** Physical key (e.code) → Tauri accelerator name, layout-independent (works on ru). */
|
/** Physical key (e.code) → Tauri accelerator name, layout-independent (works on ru). */
|
||||||
function normalizeKeyName(e: KeyboardEvent): string | null {
|
function normalizeKeyName(e: KeyboardEvent): string | null {
|
||||||
const code = e.code;
|
const code = e.code;
|
||||||
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
if (/^Key[A-Z]$/.test(code)) {return code.slice(3);}
|
||||||
if (/^Digit\d$/.test(code)) return code.slice(5);
|
if (/^Digit\d$/.test(code)) {return code.slice(5);}
|
||||||
if (/^Numpad\d$/.test(code)) return code;
|
if (/^Numpad\d$/.test(code)) {return code;}
|
||||||
if (/^F\d{1,2}$/.test(code)) return code;
|
if (/^F\d{1,2}$/.test(code)) {return code;}
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
|
Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete",
|
||||||
Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
|
Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown",
|
||||||
|
|
@ -512,12 +514,12 @@ function normalizeKeyName(e: KeyboardEvent): string | null {
|
||||||
// ---------- tauri integration
|
// ---------- 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;
|
if (!isTauri()) {return true;}
|
||||||
try {
|
try {
|
||||||
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
const gs = await import("@tauri-apps/plugin-global-shortcut");
|
||||||
if (old) await gs.unregister(old).catch(() => {});
|
if (old) {await gs.unregister(old).catch(() => {});}
|
||||||
await gs.register(combo, async (e) => {
|
await gs.register(combo, async (e) => {
|
||||||
if (e.state !== "Pressed") return;
|
if (e.state !== "Pressed") {return;}
|
||||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
if (await win.isVisible()) {
|
if (await win.isVisible()) {
|
||||||
|
|
@ -540,18 +542,18 @@ async function registerHotkey(old: string | null, combo: string): Promise<boolea
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAutostart(enabled: boolean): Promise<void> {
|
async function applyAutostart(enabled: boolean): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
try {
|
try {
|
||||||
const auto = await import("@tauri-apps/plugin-autostart");
|
const auto = await import("@tauri-apps/plugin-autostart");
|
||||||
if (enabled) await auto.enable();
|
if (enabled) {await auto.enable();}
|
||||||
else await auto.disable();
|
else {await auto.disable();}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("autostart failed", e);
|
console.warn("autostart failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
async function applyAlwaysOnTop(enabled: boolean): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||||
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e));
|
await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => console.warn("always-on-top failed", e));
|
||||||
}
|
}
|
||||||
|
|
@ -569,7 +571,7 @@ async function refreshRates(force = false): Promise<void> {
|
||||||
workspace.invalidateAll();
|
workspace.invalidateAll();
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
ratesFetchedAt = payload.fetchedAt;
|
ratesFetchedAt = payload.fetchedAt;
|
||||||
if (force) toast(t("ratesUpdated"));
|
if (force) {toast(t("ratesUpdated"));}
|
||||||
}
|
}
|
||||||
renderRatesInfo();
|
renderRatesInfo();
|
||||||
}
|
}
|
||||||
|
|
@ -585,14 +587,14 @@ let marketFetchedAt = 0;
|
||||||
function applyAllRates(): void {
|
function applyAllRates(): void {
|
||||||
const marketRates: Record<string, number> = {};
|
const marketRates: Record<string, number> = {};
|
||||||
for (const [sym, price] of Object.entries(marketPrices)) {
|
for (const [sym, price] of Object.entries(marketPrices)) {
|
||||||
if (price > 0) marketRates[sym] = 1 / price;
|
if (price > 0) {marketRates[sym] = 1 / price;}
|
||||||
}
|
}
|
||||||
engine.setRates({ ...liveRates, ...marketRates });
|
engine.setRates({ ...liveRates, ...marketRates });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshMarket(force = false): Promise<void> {
|
async function refreshMarket(force = false): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
const info = $("#market-info") as HTMLElement;
|
const info = $("#market-info");
|
||||||
info.style.display = "";
|
info.style.display = "";
|
||||||
info.classList.add("spin");
|
info.classList.add("spin");
|
||||||
info.textContent = t("market");
|
info.textContent = t("market");
|
||||||
|
|
@ -604,7 +606,7 @@ async function refreshMarket(force = false): Promise<void> {
|
||||||
applyAllRates();
|
applyAllRates();
|
||||||
workspace.invalidateAll();
|
workspace.invalidateAll();
|
||||||
editor.refresh();
|
editor.refresh();
|
||||||
if (force) toast(t("marketUpdated"));
|
if (force) {toast(t("marketUpdated"));}
|
||||||
}
|
}
|
||||||
renderMarketInfo();
|
renderMarketInfo();
|
||||||
}
|
}
|
||||||
|
|
@ -615,7 +617,7 @@ const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi;
|
||||||
|
|
||||||
async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
||||||
const dates = new Set<string>();
|
const dates = new Set<string>();
|
||||||
for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!);
|
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;
|
let fetched = false;
|
||||||
if (datesToFetch.length > 0) {
|
if (datesToFetch.length > 0) {
|
||||||
|
|
@ -632,7 +634,7 @@ async function fetchNeededHistoricalRates(text: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarketInfo(): void {
|
function renderMarketInfo(): void {
|
||||||
const el = $("#market-info") as HTMLElement;
|
const el = $("#market-info");
|
||||||
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
|
if (marketFetchedAt === 0) { el.style.display = "none"; return; }
|
||||||
el.style.display = "";
|
el.style.display = "";
|
||||||
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`;
|
||||||
|
|
@ -667,9 +669,9 @@ function renderTotal(): void {
|
||||||
|
|
||||||
function agoText(unixSec: number): string {
|
function agoText(unixSec: number): string {
|
||||||
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
||||||
if (s < 90) return t("justNow");
|
if (s < 90) {return t("justNow");}
|
||||||
if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60)));
|
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)));
|
return t("dayAgo").replace("{}", String(Math.round(s / 86400)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,7 +720,7 @@ function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "
|
||||||
// a click that just refocused the window (native dialog closing, or
|
// a click that just refocused the window (native dialog closing, or
|
||||||
// bringing a backgrounded tray window to front) lands on the backdrop
|
// bringing a backgrounded tray window to front) lands on the backdrop
|
||||||
// too — ignore a backdrop dismiss that arrives right after refocus
|
// 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);}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -730,7 +732,7 @@ function toast(msg: string): void {
|
||||||
const el = $("#toast");
|
const el = $("#toast");
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.classList.remove("hidden");
|
el.classList.remove("hidden");
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
if (toastTimer) {clearTimeout(toastTimer);}
|
||||||
toastTimer = setTimeout(() => el.classList.add("hidden"), 1200);
|
toastTimer = setTimeout(() => el.classList.add("hidden"), 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -764,7 +766,7 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
const id = uid();
|
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();
|
const scripts = await loadExtensionScripts();
|
||||||
await runExtensions(engine, scripts);
|
await runExtensions(engine, scripts);
|
||||||
|
|
@ -816,7 +818,7 @@ async function initDataAndEngine(): Promise<void> {
|
||||||
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,
|
t,
|
||||||
onOpen: (docId, line) => {
|
onOpen: (docId, line) => {
|
||||||
if (docId !== data.activeId) switchDoc(docId);
|
if (docId !== data.activeId) {switchDoc(docId);}
|
||||||
editor.goToLine(line);
|
editor.goToLine(line);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -832,7 +834,7 @@ function bindTitleUI(): void {
|
||||||
doc.title = v.slice(0, 60);
|
doc.title = v.slice(0, 60);
|
||||||
doc.customTitle = true;
|
doc.customTitle = true;
|
||||||
const rewrites = workspace.renameSheet(doc.id, oldTitle, doc.title);
|
const rewrites = workspace.renameSheet(doc.id, oldTitle, doc.title);
|
||||||
for (const r of rewrites) data.contents[r.id] = r.text;
|
for (const r of rewrites) {data.contents[r.id] = r.text;}
|
||||||
workspace.invalidateAll();
|
workspace.invalidateAll();
|
||||||
} else {
|
} else {
|
||||||
doc.customTitle = false;
|
doc.customTitle = false;
|
||||||
|
|
@ -844,7 +846,7 @@ function bindTitleUI(): void {
|
||||||
saveAppData(data);
|
saveAppData(data);
|
||||||
});
|
});
|
||||||
titleField.addEventListener("keydown", (e) => {
|
titleField.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter" || e.key === "Escape") titleField.blur();
|
if (e.key === "Enter" || e.key === "Escape") {titleField.blur();}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -887,8 +889,8 @@ function bindSidebarUI(): void {
|
||||||
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");
|
panel.classList.add("hidden");
|
||||||
}
|
}
|
||||||
if (!settings.sidebarVisible) return;
|
if (!settings.sidebarVisible) {return;}
|
||||||
if (target.closest("#sidebar") || target.closest("#toggle-sidebar")) return;
|
if (target.closest("#sidebar") || target.closest("#toggle-sidebar")) {return;}
|
||||||
settings.sidebarVisible = false;
|
settings.sidebarVisible = false;
|
||||||
$("#sidebar").classList.add("hidden");
|
$("#sidebar").classList.add("hidden");
|
||||||
void saveSettings(settings);
|
void saveSettings(settings);
|
||||||
|
|
@ -957,7 +959,7 @@ function bindExportUI(): void {
|
||||||
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
|
document.addEventListener("click", () => exportMenu.classList.add("hidden"));
|
||||||
exportMenu.addEventListener("click", async (e) => {
|
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;
|
if (!btn) {return;}
|
||||||
exportMenu.classList.add("hidden");
|
exportMenu.classList.add("hidden");
|
||||||
const action = btn.dataset.action;
|
const action = btn.dataset.action;
|
||||||
if (action === "image") {
|
if (action === "image") {
|
||||||
|
|
@ -999,9 +1001,9 @@ function bindRatesAndMarketUI(): void {
|
||||||
async function bindLifecycle(): Promise<void> {
|
async function bindLifecycle(): Promise<void> {
|
||||||
void onFileDrop((content) => newDoc(content));
|
void onFileDrop((content) => newDoc(content));
|
||||||
|
|
||||||
if (!(await registerHotkey(null, settings.hotkey))) toast(t("hotkeyFailed"));
|
if (!(await registerHotkey(null, settings.hotkey))) {toast(t("hotkeyFailed"));}
|
||||||
if (settings.autostart) await applyAutostart(true);
|
if (settings.autostart) {await applyAutostart(true);}
|
||||||
if (settings.alwaysOnTop) await applyAlwaysOnTop(true);
|
if (settings.alwaysOnTop) {await applyAlwaysOnTop(true);}
|
||||||
void onAppQuit(() => flushAppData());
|
void onAppQuit(() => flushAppData());
|
||||||
void refreshRates();
|
void refreshRates();
|
||||||
setInterval(() => void refreshRates(), 60 * 60 * 1000);
|
setInterval(() => void refreshRates(), 60 * 60 * 1000);
|
||||||
|
|
@ -1009,7 +1011,7 @@ async function bindLifecycle(): Promise<void> {
|
||||||
setInterval(() => void refreshMarket(), 15 * 60 * 1000);
|
setInterval(() => void refreshMarket(), 15 * 60 * 1000);
|
||||||
|
|
||||||
const launched = await getLaunchFile();
|
const launched = await getLaunchFile();
|
||||||
if (launched) newDoc(launched);
|
if (launched) {newDoc(launched);}
|
||||||
void onOpenFile((content) => newDoc(content));
|
void onOpenFile((content) => newDoc(content));
|
||||||
|
|
||||||
editor.focus();
|
editor.focus();
|
||||||
|
|
@ -1023,12 +1025,12 @@ async function bindLifecycle(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.autoUpdateEnabled) void checkUpdate();
|
if (settings.autoUpdateEnabled) {void checkUpdate();}
|
||||||
// a tray app can stay running for weeks without a cold restart, and the
|
// a tray app can stay running for weeks without a cold restart, and the
|
||||||
// update check above only ever runs once at boot — recheck periodically
|
// update check above only ever runs once at boot — recheck periodically
|
||||||
// so a long-lived process still notices new releases; re-read the setting
|
// 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
|
// 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> {
|
async function initUI(): Promise<void> {
|
||||||
|
|
@ -1059,15 +1061,15 @@ async function boot(): Promise<void> {
|
||||||
async function checkUpdate(manual = false): Promise<void> {
|
async function checkUpdate(manual = false): Promise<void> {
|
||||||
const result = await checkForUpdate();
|
const result = await checkForUpdate();
|
||||||
if (result === "error") {
|
if (result === "error") {
|
||||||
if (manual) toast(t("updateCheckFailed"));
|
if (manual) {toast(t("updateCheckFailed"));}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
if (manual) toast(t("upToDate"));
|
if (manual) {toast(t("upToDate"));}
|
||||||
return;
|
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;
|
if (choice !== "a") {return;}
|
||||||
toast(t("updateInstalling"));
|
toast(t("updateInstalling"));
|
||||||
try {
|
try {
|
||||||
await result.install();
|
await result.install();
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export async function loadSettings(): Promise<SettingsData> {
|
||||||
const raw = isTauri()
|
const raw = isTauri()
|
||||||
? await invoke<string | null>("load_file", { name: "settings.json" })
|
? await invoke<string | null>("load_file", { name: "settings.json" })
|
||||||
: localStorage.getItem("summarum.settings");
|
: localStorage.getItem("summarum.settings");
|
||||||
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
if (raw) {return { ...defaultSettingsData, ...JSON.parse(raw) };}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("loadSettings failed", e);
|
console.warn("loadSettings failed", e);
|
||||||
}
|
}
|
||||||
|
|
@ -86,8 +86,8 @@ export async function loadSettings(): Promise<SettingsData> {
|
||||||
|
|
||||||
export async function saveSettings(s: SettingsData): Promise<void> {
|
export async function saveSettings(s: SettingsData): Promise<void> {
|
||||||
const raw = JSON.stringify(s, null, 2);
|
const raw = JSON.stringify(s, null, 2);
|
||||||
if (isTauri()) await invoke("save_file", { name: "settings.json", contents: raw });
|
if (isTauri()) {await invoke("save_file", { name: "settings.json", contents: raw });}
|
||||||
else localStorage.setItem("summarum.settings", raw);
|
else {localStorage.setItem("summarum.settings", raw);}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- documents
|
// ---------- documents
|
||||||
|
|
@ -97,7 +97,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
|
||||||
const raw = isTauri()
|
const raw = isTauri()
|
||||||
? await invoke<string | null>("load_documents", { dir: dirArg(dataDir) })
|
? await invoke<string | null>("load_documents", { dir: dirArg(dataDir) })
|
||||||
: localStorage.getItem("summarum.documents");
|
: localStorage.getItem("summarum.documents");
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) {return JSON.parse(raw);}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("loadAppData failed", e);
|
console.warn("loadAppData failed", e);
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ export function setDataDir(dataDir: string): void {
|
||||||
export function saveAppData(data: AppData): void {
|
export function saveAppData(data: AppData): void {
|
||||||
// debounce: autosave on every keystroke without disk churn
|
// debounce: autosave on every keystroke without disk churn
|
||||||
pendingData = data;
|
pendingData = data;
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) {clearTimeout(saveTimer);}
|
||||||
saveTimer = setTimeout(() => void flushAppData(), 400);
|
saveTimer = setTimeout(() => void flushAppData(), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,12 +125,12 @@ export async function flushAppData(): Promise<void> {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = null;
|
saveTimer = null;
|
||||||
}
|
}
|
||||||
if (!pendingData) return;
|
if (!pendingData) {return;}
|
||||||
const raw = JSON.stringify(pendingData);
|
const raw = JSON.stringify(pendingData);
|
||||||
pendingData = null;
|
pendingData = null;
|
||||||
try {
|
try {
|
||||||
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
if (isTauri()) {await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });}
|
||||||
else localStorage.setItem("summarum.documents", raw);
|
else {localStorage.setItem("summarum.documents", raw);}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("saveAppData failed", e);
|
console.warn("saveAppData failed", e);
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ export async function flushAppData(): Promise<void> {
|
||||||
|
|
||||||
/** quit initiated from the tray: flush unsaved edits, then exit for real */
|
/** quit initiated from the tray: flush unsaved edits, then exit for real */
|
||||||
export async function onAppQuit(cb: () => Promise<void>): Promise<void> {
|
export async function onAppQuit(cb: () => Promise<void>): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
await listen("app-quit", () => void cb().finally(() => void invoke("exit_app")));
|
await listen("app-quit", () => void cb().finally(() => void invoke("exit_app")));
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +146,7 @@ export async function onAppQuit(cb: () => Promise<void>): Promise<void> {
|
||||||
// ---------- backups & data folder
|
// ---------- backups & data folder
|
||||||
|
|
||||||
export async function runBackups(dataDir: string, retentionDays: number): Promise<void> {
|
export async function runBackups(dataDir: string, retentionDays: number): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
try {
|
try {
|
||||||
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -155,7 +155,7 @@ export async function runBackups(dataDir: string, retentionDays: number): Promis
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function backupDeletedSheet(dataDir: string, title: string, contents: string): Promise<void> {
|
export async function backupDeletedSheet(dataDir: string, title: string, contents: string): Promise<void> {
|
||||||
if (!isTauri() || !contents.trim()) return;
|
if (!isTauri() || !contents.trim()) {return;}
|
||||||
try {
|
try {
|
||||||
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -164,24 +164,24 @@ export async function backupDeletedSheet(dataDir: string, title: string, content
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openBackupsFolder(dataDir: string): Promise<void> {
|
export async function openBackupsFolder(dataDir: string): Promise<void> {
|
||||||
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
|
if (isTauri()) {await invoke("open_backups_folder", { dir: dirArg(dataDir) });}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */
|
/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */
|
||||||
export async function chooseFolder(): Promise<string | null> {
|
export async function chooseFolder(): Promise<string | null> {
|
||||||
if (!isTauri()) return null;
|
if (!isTauri()) {return null;}
|
||||||
return (await invoke<string | null>("pick_data_dir")) ?? null;
|
return (await invoke<string | null>("pick_data_dir")) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dataDirHasDocuments(dir: string): Promise<boolean> {
|
export async function dataDirHasDocuments(dir: string): Promise<boolean> {
|
||||||
if (!isTauri()) return false;
|
if (!isTauri()) {return false;}
|
||||||
return invoke<boolean>("data_dir_has_documents", { dir });
|
return invoke<boolean>("data_dir_has_documents", { dir });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MigrateStrategy = "move" | "overwrite" | "use_existing";
|
export type MigrateStrategy = "move" | "overwrite" | "use_existing";
|
||||||
|
|
||||||
export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise<void> {
|
export async function migrateDataDir(oldDir: string, newDir: string, strategy: MigrateStrategy): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy });
|
await invoke("migrate_data_dir", { oldDir: dirArg(oldDir), newDir, strategy });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
||||||
// ---------- historical rates
|
// ---------- historical rates
|
||||||
|
|
||||||
export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record<string, Record<string, number>>> {
|
export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record<string, Record<string, number>>> {
|
||||||
if (dates.length === 0) return {};
|
if (dates.length === 0) {return {};}
|
||||||
try {
|
try {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
return await invoke<Record<string, Record<string, number>>>("fetch_historical_rates_batch", { dates });
|
return await invoke<Record<string, Record<string, number>>>("fetch_historical_rates_batch", { dates });
|
||||||
|
|
@ -222,7 +222,7 @@ export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
dates.map(async (date) => {
|
dates.map(async (date) => {
|
||||||
const rates = await fetchHistoricalRates(date);
|
const rates = await fetchHistoricalRates(date);
|
||||||
if (rates) results[date] = rates;
|
if (rates) {results[date] = rates;}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -239,10 +239,10 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
|
||||||
}
|
}
|
||||||
const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`);
|
const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!json?.rates) return null;
|
if (!json?.rates) {return null;}
|
||||||
const out: Record<string, number> = { USD: 1 };
|
const out: Record<string, number> = { USD: 1 };
|
||||||
for (const [k, v] of Object.entries(json.rates as Record<string, number>)) {
|
for (const [k, v] of Object.entries(json.rates as Record<string, number>)) {
|
||||||
if (v > 0) out[k.toUpperCase()] = v;
|
if (v > 0) {out[k.toUpperCase()] = v;}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -254,14 +254,14 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
|
||||||
// ---------- image export
|
// ---------- image export
|
||||||
|
|
||||||
export async function writeImageFile(dataBase64: string): Promise<boolean> {
|
export async function writeImageFile(dataBase64: string): Promise<boolean> {
|
||||||
if (!isTauri()) return false;
|
if (!isTauri()) {return false;}
|
||||||
return invoke("write_image_file", { dataBase64 });
|
return invoke("write_image_file", { dataBase64 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- market data
|
// ---------- market data
|
||||||
|
|
||||||
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {
|
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {
|
||||||
if (!isTauri() || symbols.length === 0) return {};
|
if (!isTauri() || symbols.length === 0) {return {};}
|
||||||
try {
|
try {
|
||||||
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -273,7 +273,7 @@ export async function fetchMarketData(symbols: string[]): Promise<Record<string,
|
||||||
// ---------- extensions
|
// ---------- extensions
|
||||||
|
|
||||||
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
||||||
if (!isTauri()) return [];
|
if (!isTauri()) {return [];}
|
||||||
try {
|
try {
|
||||||
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -283,13 +283,13 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openExtensionsFolder(): Promise<void> {
|
export async function openExtensionsFolder(): Promise<void> {
|
||||||
if (isTauri()) await invoke("open_extensions_folder");
|
if (isTauri()) {await invoke("open_extensions_folder");}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- .numi file association
|
// ---------- .numi file association
|
||||||
|
|
||||||
export async function getLaunchFile(): Promise<string | null> {
|
export async function getLaunchFile(): Promise<string | null> {
|
||||||
if (!isTauri()) return null;
|
if (!isTauri()) {return null;}
|
||||||
try {
|
try {
|
||||||
return await invoke<string | null>("get_launch_file");
|
return await invoke<string | null>("get_launch_file");
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -298,7 +298,7 @@ export async function getLaunchFile(): Promise<string | null> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onOpenFile(cb: (content: string) => void): Promise<void> {
|
export async function onOpenFile(cb: (content: string) => void): Promise<void> {
|
||||||
if (!isTauri()) return;
|
if (!isTauri()) {return;}
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
await listen<string>("open-file", (e) => cb(e.payload));
|
await listen<string>("open-file", (e) => cb(e.payload));
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +308,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
|
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
|
||||||
await getCurrentWebview().onDragDropEvent(async (event) => {
|
await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||||
if (event.payload.type !== "drop") return;
|
if (event.payload.type !== "drop") {return;}
|
||||||
for (const path of event.payload.paths) {
|
for (const path of event.payload.paths) {
|
||||||
try {
|
try {
|
||||||
const content = await invoke<string>("read_text_file", { path });
|
const content = await invoke<string>("read_text_file", { path });
|
||||||
|
|
@ -324,7 +324,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
|
||||||
window.addEventListener("drop", async (e) => {
|
window.addEventListener("drop", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
for (const f of e.dataTransfer?.files ?? []) {
|
for (const f of e.dataTransfer?.files ?? []) {
|
||||||
if (/\.(numi|txt|md)$/i.test(f.name)) cb(await f.text());
|
if (/\.(numi|txt|md)$/i.test(f.name)) {cb(await f.text());}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// CodeMirror editor wired to the engine: evaluates on change, highlights
|
// CodeMirror editor wired to the engine: evaluates on change, highlights
|
||||||
// engine tokens, renders the results overlay, autocompletes phrases.
|
// engine tokens, renders the results overlay, autocompletes phrases.
|
||||||
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
|
import type { ViewUpdate, DecorationSet} from "@codemirror/view";
|
||||||
|
import { EditorView, ViewPlugin, Decoration, keymap, drawSelection } from "@codemirror/view";
|
||||||
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
|
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
|
||||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||||
import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
|
import type { CompletionContext, CompletionResult} from "@codemirror/autocomplete";
|
||||||
import { SumEngine, LineResult } from "../engine";
|
import { autocompletion, acceptCompletion } from "@codemirror/autocomplete";
|
||||||
|
import type { SumEngine, LineResult } from "../engine";
|
||||||
import { buildSparkline } from "./sparkline";
|
import { buildSparkline } from "./sparkline";
|
||||||
|
|
||||||
export interface EditorCallbacks {
|
export interface EditorCallbacks {
|
||||||
|
|
@ -20,7 +22,7 @@ const setResults = StateEffect.define<LineResult[]>();
|
||||||
const resultsField = StateField.define<LineResult[]>({
|
const resultsField = StateField.define<LineResult[]>({
|
||||||
create: () => [],
|
create: () => [],
|
||||||
update(value, tr) {
|
update(value, tr) {
|
||||||
for (const e of tr.effects) if (e.is(setResults)) value = e.value;
|
for (const e of tr.effects) {if (e.is(setResults)) {value = e.value;}}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -56,7 +58,7 @@ function buildDecorations(state: EditorState): DecorationSet {
|
||||||
marks.sort((a, b) => a.from - b.from);
|
marks.sort((a, b) => a.from - b.from);
|
||||||
let last = line.from;
|
let last = line.from;
|
||||||
for (const m of marks) {
|
for (const m of marks) {
|
||||||
if (m.from < last) continue; // overlapping (comment vs tokens) — first wins
|
if (m.from < last) {continue;} // overlapping (comment vs tokens) — first wins
|
||||||
builder.add(m.from, m.to, Decoration.mark({ class: m.cls }));
|
builder.add(m.from, m.to, Decoration.mark({ class: m.cls }));
|
||||||
last = m.to;
|
last = m.to;
|
||||||
}
|
}
|
||||||
|
|
@ -68,8 +70,8 @@ const highlightField = StateField.define<DecorationSet>({
|
||||||
create: buildDecorations,
|
create: buildDecorations,
|
||||||
update(value, tr) {
|
update(value, tr) {
|
||||||
const hasResults = tr.effects.some((e) => e.is(setResults));
|
const hasResults = tr.effects.some((e) => e.is(setResults));
|
||||||
if (hasResults) return buildDecorations(tr.state);
|
if (hasResults) {return buildDecorations(tr.state);}
|
||||||
if (tr.docChanged) return value.map(tr.changes);
|
if (tr.docChanged) {return value.map(tr.changes);}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
|
|
@ -79,20 +81,20 @@ const highlightField = StateField.define<DecorationSet>({
|
||||||
function buildVarHighlight(state: EditorState): DecorationSet {
|
function buildVarHighlight(state: EditorState): DecorationSet {
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
const sel = state.selection.main;
|
const sel = state.selection.main;
|
||||||
if (!sel.empty) return builder.finish();
|
if (!sel.empty) {return builder.finish();}
|
||||||
const text = state.doc.toString();
|
const text = state.doc.toString();
|
||||||
// expand a word around the cursor
|
// expand a word around the cursor
|
||||||
const isWordCh = (ch: string) => /[\p{L}\d_]/u.test(ch);
|
const isWordCh = (ch: string) => /[\p{L}\d_]/u.test(ch);
|
||||||
let a = sel.head;
|
let a = sel.head;
|
||||||
let b = sel.head;
|
let b = sel.head;
|
||||||
while (a > 0 && isWordCh(text[a - 1])) a--;
|
while (a > 0 && isWordCh(text[a - 1])) {a--;}
|
||||||
while (b < text.length && isWordCh(text[b])) b++;
|
while (b < text.length && isWordCh(text[b])) {b++;}
|
||||||
if (a === b) return builder.finish();
|
if (a === b) {return builder.finish();}
|
||||||
const word = text.slice(a, b);
|
const word = text.slice(a, b);
|
||||||
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
|
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) {return builder.finish();}
|
||||||
// only highlight if the word is actually assigned somewhere in the document
|
// only highlight if the word is actually assigned somewhere in the document
|
||||||
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish();
|
if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) {return builder.finish();}
|
||||||
const re = new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
|
const re = new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
|
||||||
for (const m of text.matchAll(re)) {
|
for (const m of text.matchAll(re)) {
|
||||||
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
||||||
|
|
@ -106,9 +108,9 @@ const varHighlightField = StateField.define<DecorationSet>({
|
||||||
create: () => Decoration.none,
|
create: () => Decoration.none,
|
||||||
update(value, tr) {
|
update(value, tr) {
|
||||||
for (const e of tr.effects) {
|
for (const e of tr.effects) {
|
||||||
if (e.is(setVarHighlight)) return e.value;
|
if (e.is(setVarHighlight)) {return e.value;}
|
||||||
}
|
}
|
||||||
if (tr.docChanged) return value.map(tr.changes);
|
if (tr.docChanged) {return value.map(tr.changes);}
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
|
|
@ -129,7 +131,7 @@ const varHighlightPlugin = ViewPlugin.fromClass(
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule(view: EditorView) {
|
schedule(view: EditorView) {
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
if (this.timeout) {clearTimeout(this.timeout);}
|
||||||
this.timeout = setTimeout(() => {
|
this.timeout = setTimeout(() => {
|
||||||
const decos = buildVarHighlight(view.state);
|
const decos = buildVarHighlight(view.state);
|
||||||
view.dispatch({ effects: setVarHighlight.of(decos) });
|
view.dispatch({ effects: setVarHighlight.of(decos) });
|
||||||
|
|
@ -137,7 +139,7 @@ const varHighlightPlugin = ViewPlugin.fromClass(
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
if (this.timeout) {clearTimeout(this.timeout);}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -160,8 +162,8 @@ export class SumEditor {
|
||||||
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
||||||
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
||||||
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
||||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
if (!word || (word.from === word.to && !ctx.explicit)) {return null;}
|
||||||
if (word.to - word.from < 2 && !ctx.explicit) return null;
|
if (word.to - word.from < 2 && !ctx.explicit) {return null;}
|
||||||
// the engine's phrase list is fixed after boot — map it once
|
// the engine's phrase list is fixed after boot — map it once
|
||||||
engineOptions ??= this.engine.completions().map((c) => ({
|
engineOptions ??= this.engine.completions().map((c) => ({
|
||||||
label: c.label,
|
label: c.label,
|
||||||
|
|
@ -243,7 +245,7 @@ export class SumEditor {
|
||||||
const lineTo = doc.lineAt(sel.to);
|
const lineTo = doc.lineAt(sel.to);
|
||||||
const multiLine = lineFrom.number !== lineTo.number;
|
const multiLine = lineFrom.number !== lineTo.number;
|
||||||
const coversLine = sel.from <= lineFrom.from && sel.to >= lineTo.to && !sel.empty;
|
const coversLine = sel.from <= lineFrom.from && sel.to >= lineTo.to && !sel.empty;
|
||||||
if (!sel.empty && !multiLine && !coversLine) return;
|
if (!sel.empty && !multiLine && !coversLine) {return;}
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (let n = lineFrom.number; n <= lineTo.number; n++) {
|
for (let n = lineFrom.number; n <= lineTo.number; n++) {
|
||||||
const line = doc.line(n);
|
const line = doc.line(n);
|
||||||
|
|
@ -255,8 +257,8 @@ export class SumEditor {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.resultsEl.addEventListener("click", (e) => {
|
this.resultsEl.addEventListener("click", (e) => {
|
||||||
const el = (e.target as HTMLElement).closest(".result-line") as HTMLElement | null;
|
const el = (e.target as HTMLElement).closest(".result-line");
|
||||||
if (el?.dataset.value) this.cb.onCopy(el.dataset.value);
|
if (el?.dataset.value) {this.cb.onCopy(el.dataset.value);}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,19 +320,19 @@ export class SumEditor {
|
||||||
const r = this.results[i];
|
const r = this.results[i];
|
||||||
const isChart = r.value?.kind === "chart";
|
const isChart = r.value?.kind === "chart";
|
||||||
const isError = !!r.error;
|
const isError = !!r.error;
|
||||||
if (!r.text && !isChart && !isError) continue;
|
if (!r.text && !isChart && !isError) {continue;}
|
||||||
const line = doc.line(i + 1);
|
const line = doc.line(i + 1);
|
||||||
const block = this.view.lineBlockAt(line.from);
|
const block = this.view.lineBlockAt(line.from);
|
||||||
const top = docTop + block.top - overlayTop;
|
const top = docTop + block.top - overlayTop;
|
||||||
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
|
if (top < -40 || top > this.resultsEl.clientHeight + 40) {continue;}
|
||||||
if (isChart && r.value?.kind === "chart") {
|
if (isChart && r.value?.kind === "chart") {
|
||||||
frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top));
|
frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top));
|
||||||
} else {
|
} else {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "result-line" + (isError ? " result-error" : "");
|
el.className = "result-line" + (isError ? " result-error" : "");
|
||||||
el.textContent = isError ? "#ref?" : r.text;
|
el.textContent = isError ? "#ref?" : r.text;
|
||||||
if (!isError) el.dataset.value = r.text!;
|
if (!isError) {el.dataset.value = r.text!;}
|
||||||
if (isError) el.title = r.error!;
|
if (isError) {el.title = r.error!;}
|
||||||
el.style.top = `${top}px`;
|
el.style.top = `${top}px`;
|
||||||
frag.appendChild(el);
|
frag.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Full-text and numeric ("> 100", "~ 50") search across every open sheet.
|
// Full-text and numeric ("> 100", "~ 50") search across every open sheet.
|
||||||
// Owns the #search-overlay DOM and its own input debounce.
|
// Owns the #search-overlay DOM and its own input debounce.
|
||||||
import { SumEngine, Value } from "../engine";
|
import type { SumEngine, Value } from "../engine";
|
||||||
import { Workspace } from "../workspace";
|
import type { Workspace } from "../workspace";
|
||||||
|
|
||||||
export interface SearchHit {
|
export interface SearchHit {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
|
@ -34,15 +34,15 @@ const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel)
|
||||||
|
|
||||||
function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
|
function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
|
||||||
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
||||||
if (!m) return null;
|
if (!m) {return null;}
|
||||||
const v = engine.evaluateExpression(m[2].trim());
|
const v = engine.evaluateExpression(m[2].trim());
|
||||||
if (!v || v.kind !== "quantity") return null;
|
if (!v || v.kind !== "quantity") {return null;}
|
||||||
return { op: m[1], threshold: v };
|
return { op: m[1], threshold: v };
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return [];
|
if (!q) {return [];}
|
||||||
const docs = deps.docs();
|
const docs = deps.docs();
|
||||||
const rq = parseResultQuery(deps.engine, q);
|
const rq = parseResultQuery(deps.engine, q);
|
||||||
if (rq && rq.threshold.kind === "quantity") {
|
if (rq && rq.threshold.kind === "quantity") {
|
||||||
|
|
@ -53,7 +53,7 @@ function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
const lines = doc.text.split("\n");
|
const lines = doc.text.split("\n");
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
if (!r.value || r.value.kind !== "quantity") continue;
|
if (!r.value || r.value.kind !== "quantity") {continue;}
|
||||||
const v = r.value.value;
|
const v = r.value.value;
|
||||||
let match = false;
|
let match = false;
|
||||||
switch (rq.op) {
|
switch (rq.op) {
|
||||||
|
|
@ -66,7 +66,7 @@ function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
}
|
}
|
||||||
if (match) {
|
if (match) {
|
||||||
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined });
|
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined });
|
||||||
if (hits.length >= 200) return hits;
|
if (hits.length >= 200) {return hits;}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +79,7 @@ function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
if (lines[i].toLowerCase().includes(ql)) {
|
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;
|
if (hits.length >= 200) {return hits;}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ export function initSearch(deps: SearchDeps): SearchController {
|
||||||
// doesn't recompute the whole workspace on every keystroke
|
// doesn't recompute the whole workspace on every keystroke
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) {clearTimeout(searchTimer);}
|
||||||
const q = input.value;
|
const q = input.value;
|
||||||
searchTimer = setTimeout(() => render(q, searchAllSheets(deps, q)), 150);
|
searchTimer = setTimeout(() => render(q, searchAllSheets(deps, q)), 150);
|
||||||
});
|
});
|
||||||
|
|
@ -140,7 +140,7 @@ export function initSearch(deps: SearchDeps): SearchController {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
overlay.addEventListener("mousedown", (e) => {
|
overlay.addEventListener("mousedown", (e) => {
|
||||||
if (e.target === overlay) close();
|
if (e.target === overlay) {close();}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export function buildSparkline(pts: number[], top: number): SVGSVGElement {
|
export function buildSparkline(pts: number[], top: number): SVGSVGElement {
|
||||||
const NS = "http://www.w3.org/2000/svg";
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
const W = 80, H = 20, pad = 2;
|
const W = 80, H = 20, pad = 2;
|
||||||
const svg = document.createElementNS(NS, "svg") as SVGSVGElement;
|
const svg = document.createElementNS(NS, "svg");
|
||||||
svg.setAttribute("width", String(W));
|
svg.setAttribute("width", String(W));
|
||||||
svg.setAttribute("height", String(H));
|
svg.setAttribute("height", String(H));
|
||||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface AvailableUpdate {
|
||||||
* (`vite dev`) also reports null.
|
* (`vite dev`) also reports null.
|
||||||
*/
|
*/
|
||||||
export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"> {
|
export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"> {
|
||||||
if (!isTauri()) return null;
|
if (!isTauri()) {return null;}
|
||||||
try {
|
try {
|
||||||
const { check } = await import("@tauri-apps/plugin-updater");
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
const update = await check();
|
const update = await check();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { SumEngine } from "./engine";
|
import { SumEngine } from "./engine";
|
||||||
import { Workspace, SheetSource } from "./workspace";
|
import type { SheetSource } from "./workspace";
|
||||||
|
import { Workspace } from "./workspace";
|
||||||
|
|
||||||
function makeWorkspace(initial: Record<string, { title: string; text: string }>) {
|
function makeWorkspace(initial: Record<string, { title: string; text: string }>) {
|
||||||
const engine = new SumEngine();
|
const engine = new SumEngine();
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export class Workspace {
|
||||||
while (changed) {
|
while (changed) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const s of this.sheets()) {
|
for (const s of this.sheets()) {
|
||||||
if (dirty.has(s.id)) continue;
|
if (dirty.has(s.id)) {continue;}
|
||||||
for (const title of this.referencedTitles(s.text, s.id)) {
|
for (const title of this.referencedTitles(s.text, s.id)) {
|
||||||
const target = this.findSheetByTitle(title);
|
const target = this.findSheetByTitle(title);
|
||||||
if (target && dirty.has(target.id)) {
|
if (target && dirty.has(target.id)) {
|
||||||
|
|
@ -69,7 +69,7 @@ export class Workspace {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const id of dirty) this.cache.delete(id);
|
for (const id of dirty) {this.cache.delete(id);}
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateAll(): void {
|
invalidateAll(): void {
|
||||||
|
|
@ -90,9 +90,9 @@ export class Workspace {
|
||||||
const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`;
|
const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`;
|
||||||
const out: { id: string; text: string }[] = [];
|
const out: { id: string; text: string }[] = [];
|
||||||
for (const s of this.sheets()) {
|
for (const s of this.sheets()) {
|
||||||
if (s.id === renamedId) continue;
|
if (s.id === renamedId) {continue;}
|
||||||
const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement);
|
const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement);
|
||||||
if (rewritten !== s.text) out.push({ id: s.id, text: rewritten });
|
if (rewritten !== s.text) {out.push({ id: s.id, text: rewritten });}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -100,10 +100,10 @@ export class Workspace {
|
||||||
private referencedTitles(text: string, id?: string): string[] {
|
private referencedTitles(text: string, id?: string): string[] {
|
||||||
if (id) {
|
if (id) {
|
||||||
const cached = this.parsedRefsCache.get(id);
|
const cached = this.parsedRefsCache.get(id);
|
||||||
if (cached && cached.text === text) return cached.refs;
|
if (cached && cached.text === text) {return cached.refs;}
|
||||||
}
|
}
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim());
|
for (const m of text.matchAll(XREF_SCAN_RE)) {out.push((m[1] ?? m[2]).trim());}
|
||||||
if (id) {
|
if (id) {
|
||||||
this.parsedRefsCache.set(id, { text, refs: out });
|
this.parsedRefsCache.set(id, { text, refs: out });
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +126,7 @@ export class Workspace {
|
||||||
|
|
||||||
private resolveXRef(sheetTitle: string, key: string): XRefResolution {
|
private resolveXRef(sheetTitle: string, key: string): XRefResolution {
|
||||||
const target = this.findSheetByTitle(sheetTitle);
|
const target = this.findSheetByTitle(sheetTitle);
|
||||||
if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` };
|
if (!target) {return { ok: false, reason: `sheet "${sheetTitle}" not found` };}
|
||||||
if (this.resolving.has(target.id)) {
|
if (this.resolving.has(target.id)) {
|
||||||
return { ok: false, reason: "circular reference" };
|
return { ok: false, reason: "circular reference" };
|
||||||
}
|
}
|
||||||
|
|
@ -142,17 +142,17 @@ export class Workspace {
|
||||||
: { ok: false, reason: `sheet "${sheetTitle}" has no result` };
|
: { ok: false, reason: `sheet "${sheetTitle}" has no result` };
|
||||||
}
|
}
|
||||||
const v = exports.vars.get(key);
|
const v = exports.vars.get(key);
|
||||||
if (!v) return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` };
|
if (!v) {return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` };}
|
||||||
return { ok: true, value: v };
|
return { ok: true, value: v };
|
||||||
}
|
}
|
||||||
|
|
||||||
private exportsFor(sheet: SheetSource): SheetExports {
|
private exportsFor(sheet: SheetSource): SheetExports {
|
||||||
const cached = this.cache.get(sheet.id);
|
const cached = this.cache.get(sheet.id);
|
||||||
if (cached) return cached;
|
if (cached) {return cached;}
|
||||||
const results = this.doEvaluate(sheet.id, sheet.text);
|
const results = this.doEvaluate(sheet.id, sheet.text);
|
||||||
const vars = new Map<string, Value>();
|
const vars = new Map<string, Value>();
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.assign && r.value) vars.set(r.assign, r.value);
|
if (r.assign && r.value) {vars.set(r.assign, r.value);}
|
||||||
}
|
}
|
||||||
let last: Value | null = null;
|
let last: Value | null = null;
|
||||||
for (let i = results.length - 1; i >= 0; i--) {
|
for (let i = results.length - 1; i >= 0; i--) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue