mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 18:01:59 +03:00
Compare commits
No commits in common. "main" and "v1.0.1" have entirely different histories.
64 changed files with 1044 additions and 7642 deletions
60
.github/workflows/release.yml
vendored
60
.github/workflows/release.yml
vendored
|
|
@ -14,40 +14,18 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# tauri-action's find-or-create-release check races across parallel
|
# tauri-action's find-or-create-release check races across parallel
|
||||||
# matrix jobs and can create two separate release objects for the
|
# matrix jobs and can create two separate release objects for the
|
||||||
# same tag, each missing the other's assets — force serial builds.
|
# same tag, each missing the other's assets — force serial builds
|
||||||
# Every platform added below shares that release object, so they all
|
|
||||||
# have to stay in this one serialized matrix.
|
|
||||||
max-parallel: 1
|
max-parallel: 1
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- target: x86_64-pc-windows-msvc
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
arch: x64
|
arch: x64
|
||||||
- os: windows-latest
|
- target: aarch64-pc-windows-msvc
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
arch: arm64
|
arch: arm64
|
||||||
# 22.04 is the oldest runner with webkit2gtk-4.1 (what Tauri 2
|
runs-on: windows-latest
|
||||||
# needs); building on it keeps the glibc floor as low as possible
|
|
||||||
- os: ubuntu-22.04
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
arch: x64
|
|
||||||
- os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
arch: arm64
|
|
||||||
- os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
arch: x64
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential curl wget file \
|
|
||||||
libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev \
|
|
||||||
libayatana-appindicator3-dev patchelf
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
@ -64,19 +42,9 @@ jobs:
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
# TAURI_SIGNING_* below is the updater's minisign keypair, not OS code
|
|
||||||
# signing — there is no Apple certificate, so macOS builds are only
|
|
||||||
# ad-hoc signed (bundle.macOS.signingIdentity "-") and Gatekeeper still
|
|
||||||
# asks the user to allow them on first launch.
|
|
||||||
#
|
|
||||||
# RELEASE_TOKEN (fine-grained PAT, Contents: read/write) stands in for
|
|
||||||
# the built-in GITHUB_TOKEN: the Actions-native token started getting
|
|
||||||
# "Resource not accessible by integration" on release creation for this
|
|
||||||
# repo — confirmed not a permissions:/repo-settings/tag-protection issue,
|
|
||||||
# a personal token hits the same Releases API fine.
|
|
||||||
- uses: tauri-apps/tauri-action@v0
|
- uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
|
|
@ -95,27 +63,17 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- env:
|
- env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
prev=$(git tag --sort=-v:refname | grep -v "^${{ github.ref_name }}$" | head -1)
|
prev=$(git tag --sort=-v:refname | grep -v "^${{ github.ref_name }}$" | head -1)
|
||||||
# release-checklist version bumps (bump version to X / bump cargo
|
|
||||||
# version to X / update tauri.conf version to X / sync Cargo.lock
|
|
||||||
# or package-lock.json with X) are internal bookkeeping, not
|
|
||||||
# user-facing changes — never worth a changelog line.
|
|
||||||
noise='^- (bump( cargo)? version to|update tauri\.conf version to|sync (Cargo\.lock|package-lock\.json) with) [0-9]+\.[0-9]+\.[0-9]+$'
|
|
||||||
filter() {
|
|
||||||
grep -vE "$noise" \
|
|
||||||
| sed -E 's/ \(#[0-9]+\)$//' \
|
|
||||||
| perl -CSD -pe 's/^- \p{Emoji_Presentation}\x{FE0F}?\s*/- /'
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
echo "## What's new"
|
echo "## What's new"
|
||||||
if [ -n "$prev" ]; then
|
if [ -n "$prev" ]; then
|
||||||
git log "$prev..${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | filter
|
git log "$prev..${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | grep -v "^- bump version"
|
||||||
echo ""
|
echo ""
|
||||||
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}"
|
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}"
|
||||||
else
|
else
|
||||||
git log "${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | filter
|
git log "${{ github.ref_name }}" --pretty=format:"- %s" --no-merges | grep -v "^- bump version"
|
||||||
fi
|
fi
|
||||||
} > notes.md
|
} > notes.md
|
||||||
gh release edit "${{ github.ref_name }}" --notes-file notes.md --draft=false
|
gh release edit "${{ github.ref_name }}" --notes-file notes.md
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,7 +2,6 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
src-tauri/.cargo/
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# local reference copies of third-party sources — not ours to ship
|
# local reference copies of third-party sources — not ours to ship
|
||||||
|
|
@ -11,10 +10,6 @@ numi-master/
|
||||||
|
|
||||||
# one-off debugging artifacts
|
# one-off debugging artifacts
|
||||||
scripts/smoke-*
|
scripts/smoke-*
|
||||||
test_perf.ts
|
|
||||||
|
|
||||||
# AI coding agent scratch/notes directories
|
|
||||||
.jules/
|
|
||||||
|
|
||||||
# updater signing key — private, never publish (see .tauri-signing/README.txt)
|
# updater signing key — private, never publish (see .tauri-signing/README.txt)
|
||||||
.tauri-signing/
|
.tauri-signing/
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
when:
|
|
||||||
- event: [push, manual]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test-engine
|
|
||||||
image: node:22-bookworm-slim
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npx tsc --noEmit
|
|
||||||
- npx vitest run
|
|
||||||
|
|
||||||
- name: build-tauri-linux
|
|
||||||
image: rust:1-bookworm
|
|
||||||
commands:
|
|
||||||
- rustup component add clippy
|
|
||||||
- apt-get update && apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev libayatana-appindicator3-dev
|
|
||||||
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
|
||||||
- apt-get install -y nodejs
|
|
||||||
- npm ci
|
|
||||||
- cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
|
||||||
- cargo test --manifest-path src-tauri/Cargo.toml
|
|
||||||
- CI=true npx tauri build --no-bundle --ci
|
|
||||||
210
ARCHITECTURE.md
210
ARCHITECTURE.md
|
|
@ -21,26 +21,18 @@ src/
|
||||||
index.ts SumEngine facade + extension API + sheet totals
|
index.ts SumEngine facade + extension API + sheet totals
|
||||||
ui/
|
ui/
|
||||||
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
|
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
|
||||||
search.ts cross-sheet search overlay (text + result-value queries)
|
|
||||||
export.ts renders the sheet + results to a PNG (clipboard/file)
|
|
||||||
sparkline.ts SVG sparkline renderer for `chart` results
|
|
||||||
app.css themes (light/dark via CSS variables)
|
app.css themes (light/dark via CSS variables)
|
||||||
main.ts app bootstrap: documents, settings, status bar, dnd
|
main.ts app bootstrap: documents, settings, status bar, dnd
|
||||||
workspace.ts cross-sheet references: exports cache, cycle detection
|
|
||||||
storage.ts Tauri commands / localStorage fallback
|
storage.ts Tauri commands / localStorage fallback
|
||||||
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
|
extensions.ts runs user .js files against the engine
|
||||||
to the engine's numi.* API
|
|
||||||
i18n.ts interface strings (en/ru)
|
i18n.ts interface strings (en/ru)
|
||||||
updater.ts checks tauri-plugin-updater, installs + relaunches
|
updater.ts checks tauri-plugin-updater, installs + relaunches
|
||||||
logger.ts thin console wrapper (info/warn/error)
|
|
||||||
src-tauri/
|
src-tauri/
|
||||||
src/main.rs tray, hide-to-tray, storage commands, rates fetching
|
src/main.rs tray, hide-to-tray, storage commands, rates fetching
|
||||||
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
|
(open.er-api.com + CoinGecko) with cache, backups,
|
||||||
with cache, historical rates (frankfurter.dev, permanent
|
data-folder migration, file drops, plugin registration
|
||||||
per-date cache), backups, data-folder migration,
|
(autostart, global-shortcut, single-instance, opener,
|
||||||
file drops, plugin registration (autostart,
|
dialog, updater, process)
|
||||||
global-shortcut, single-instance, opener, dialog,
|
|
||||||
updater, process)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## How a line becomes a result
|
## How a line becomes a result
|
||||||
|
|
@ -97,93 +89,11 @@ snapshot price) plus the mirror list in `src-tauri/src/main.rs`.
|
||||||
because an "attosecond" symbol would shadow the word `as`.
|
because an "attosecond" symbol would shadow the word `as`.
|
||||||
- `and`/`or` are dual-purpose: `and` adds (`5 and 3` = 8), `or` is bitwise.
|
- `and`/`or` are dual-purpose: `and` adds (`5 and 3` = 8), `or` is bitwise.
|
||||||
`of/off/on` only act on a percent left-hand side; otherwise they're prose.
|
`of/off/on` only act on a percent left-hand side; otherwise they're prose.
|
||||||
The same `on` token doubles as the historical-rate date separator (`in EUR on
|
|
||||||
2024-01-01`) — `parseSeq` checks for `pctop(on)` + `datelit` right after a
|
|
||||||
currency conv target and consumes both when present.
|
|
||||||
- Currency rates are "units per USD"; a currency Unit's ratio is computed at
|
- Currency rates are "units per USD"; a currency Unit's ratio is computed at
|
||||||
evaluation time from the current rate map, so re-setting rates re-prices
|
tokenization time from the current rate map, so re-setting rates re-prices
|
||||||
everything on the next evaluation. Historical rates live in a separate
|
everything on the next evaluation.
|
||||||
`historicalRates: Map<date, Map<code, number>>` on `SumEngine` and are
|
|
||||||
passed through `EvalCtx` to `evalConv`.
|
|
||||||
- Live market data (stocks, commodities via Yahoo Finance) is merged into the
|
|
||||||
same rate map as exchange rates via `applyAllRates()` in `main.ts` to avoid
|
|
||||||
one `setRates()` call overwriting the other.
|
|
||||||
- Goal seek (`? * 1.2 = 1000`) tokenizes `?` as `{ t: "unknown" }`, parsed
|
|
||||||
into `{ k: "unknown" }` nodes. `parseLine` detects the presence of `unknown`
|
|
||||||
+ `assign` and builds a `{ k: "goalseek", lhs, rhs }` node. The solver
|
|
||||||
tries a linear probe (f(0), f(1) → exact root for affine expressions) and
|
|
||||||
falls back to bisection for nonlinear ones.
|
|
||||||
- `ChartValue` (`kind: "chart"`) is returned by `evalAgg("chart", …)` and
|
|
||||||
bypasses `formatValue` — the results overlay renders an SVG sparkline
|
|
||||||
instead of a text string.
|
|
||||||
- The registry is rebuilt never; extensions mutate it (add phrases/functions)
|
- The registry is rebuilt never; extensions mutate it (add phrases/functions)
|
||||||
at startup. `buildRegistry()` (`registry.ts`) is a thin orchestrator over
|
at startup.
|
||||||
`registerCoreVocab` / `registerUnits` / `registerAreaVolume` /
|
|
||||||
`registerCurrencies` / `registerExtraUnits` / `registerCrypto` /
|
|
||||||
`registerCompletions` — **call order matters**, since `Registry.addPhrase`
|
|
||||||
keeps the *first* registration of an identical phrase and drops later ones
|
|
||||||
(core vocab must win over unit/currency phrases that happen to collide).
|
|
||||||
- Multiplying two same-dimension quantities (`2 kg * 500 g`) converts the
|
|
||||||
right operand into the left operand's unit before multiplying, so the
|
|
||||||
result keeps a sane unit (`1 kg`, not `1000` in a `kg` label). Cross-dimension
|
|
||||||
multiplication (`$12 * 3`, `5 hours * 2`) is unaffected — the right side is
|
|
||||||
treated as a plain scale factor there.
|
|
||||||
- A `//` only starts a comment at the start of a line or after whitespace
|
|
||||||
(`findCommentStart` in `index.ts`), so a URL like `see https://example.com`
|
|
||||||
isn't truncated at the double slash.
|
|
||||||
- **Date literals and formats.** Besides ISO (`2024-01-01`), the lexer accepts
|
|
||||||
`D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) with the same separator
|
|
||||||
on both sides and a 4-digit year. Day/month order is disambiguated per
|
|
||||||
literal: a component `>12` fixes which side is the day; if both are `≤12`,
|
|
||||||
the engine's `dateFormat` setting decides. All three literal forms reject
|
|
||||||
out-of-range components via a round-trip check (`validDateMs` in `lexer.ts`
|
|
||||||
— `new Date(y, mo-1, d)` silently *rolls over* invalid dates like
|
|
||||||
`2026-13-45` instead of erroring, so the constructed date's year/month/day
|
|
||||||
are compared back against the input). `SumEngine` resolves `dateFormat:
|
|
||||||
"system"` to an actual day/month order once via `detectDateOrder()`
|
|
||||||
(`datetime.ts`, probes `Intl.DateTimeFormat` on a `2000-12-31` fixture) and
|
|
||||||
passes it into `tokenize()` → `lexLine()` on every call; `"dmy"`/`"mdy"`
|
|
||||||
settings skip detection. Display works the same way in reverse: `formatter.ts`
|
|
||||||
either uses the OS-locale `Intl` formatting (`"system"`) or builds a fixed
|
|
||||||
`YYYY-MM-DD` / `DD.MM.YYYY` / `MM/DD/YYYY` string via
|
|
||||||
`Intl.DateTimeFormat(...).formatToParts()` (needed to respect the value's
|
|
||||||
own IANA timezone, which a hand-rolled `ms`-based formatter can't do).
|
|
||||||
`Intl.DateTimeFormat` instances are cached by options key in `formatter.ts`
|
|
||||||
(`dtf()`) since constructing one is comparatively expensive and every dated
|
|
||||||
line rebuilds its format on every render.
|
|
||||||
- Timezone name parsing (`time in Europe/Berlin`) has two independent traps.
|
|
||||||
First, `parseLine`'s noise-filter only keeps word tokens that immediately
|
|
||||||
follow a `conv` token (`in`/`as`/...); any other token type resets that
|
|
||||||
state — a bare `/` between two timezone words would normally drop the
|
|
||||||
second word before the parser ever sees it, so the filter special-cases a
|
|
||||||
`/` that appears while collecting post-conv words. Second, `parseTarget()`'s
|
|
||||||
word-collecting loop mirrors that same allowance so it keeps consuming past
|
|
||||||
the `/` instead of stopping at the first word. Both places need the
|
|
||||||
exception — fixing only one still drops `Europe/Berlin` down to `Europe`.
|
|
||||||
|
|
||||||
## Cross-sheet references
|
|
||||||
|
|
||||||
`@Sheet.key` is lexed directly in `lexer.ts` (not through the phrase
|
|
||||||
registry — sheet titles are arbitrary user text, not vocabulary), producing
|
|
||||||
an `xref` lexeme carrying `{ sheet, key }`. The engine only knows how to
|
|
||||||
*ask* for a value: `EvalCtx.resolveXRef?.(sheet, key)` returns
|
|
||||||
`{ ok: true; value } | { ok: false; reason }`; when no resolver is supplied
|
|
||||||
(tests, extensions, `evaluateExpression`) an xref is simply unresolved.
|
|
||||||
Unresolved xrefs throw `XRefError` internally, caught in
|
|
||||||
`SumEngine.evaluateDocument` and surfaced as `LineResult.error` (rendered as
|
|
||||||
`#ref?` in the results column) — every other evaluation error still resolves
|
|
||||||
to a silent blank line, unchanged.
|
|
||||||
|
|
||||||
The engine has no concept of "other sheets" — that lives entirely in
|
|
||||||
`src/workspace.ts`. `Workspace` reads all sheet texts from the app's
|
|
||||||
in-memory `data.contents`, caches each sheet's exports (its assigned
|
|
||||||
variables, `total`, `last`) after first evaluating it, and invalidates a
|
|
||||||
sheet's cache plus every transitive dependent (found via a text scan for
|
|
||||||
`@Name.`) when it's edited or rates refresh. `Workspace.evaluateSheet(id,
|
|
||||||
text)` pushes `id` onto a resolving-stack for the duration of the call, so a
|
|
||||||
sheet that references itself — directly or through another sheet — hits an
|
|
||||||
already-on-the-stack check and resolves to `circular reference` instead of
|
|
||||||
recursing forever.
|
|
||||||
|
|
||||||
## UI notes
|
## UI notes
|
||||||
|
|
||||||
|
|
@ -195,19 +105,10 @@ recursing forever.
|
||||||
scroll/geometry changes. Click-to-copy reads `data-value`.
|
scroll/geometry changes. Click-to-copy reads `data-value`.
|
||||||
- Engine evaluation is synchronous on every keystroke — a full sheet parse
|
- Engine evaluation is synchronous on every keystroke — a full sheet parse
|
||||||
is well under a millisecond, so there's no debounce or worker.
|
is well under a millisecond, so there's no debounce or worker.
|
||||||
- Search-all-sheets (`Ctrl+F`) is a plain line scan over every open sheet's
|
- Search-all-sheets (`Ctrl+F`) is a plain line scan over `data.contents` in
|
||||||
text, not a CodeMirror search extension — it has to cross documents, so it
|
`main.ts`, not a CodeMirror search extension — it has to cross documents,
|
||||||
never touches the active editor until a result is clicked
|
so it never touches the active editor until a result is clicked
|
||||||
(`editor.goToLine`). It lives in `src/ui/search.ts` (`initSearch()`), which
|
(`editor.goToLine`).
|
||||||
owns the `#search-overlay` DOM and its own 150 ms input debounce — a
|
|
||||||
result-value query (`>1000`) re-evaluates every open sheet, so debouncing
|
|
||||||
matters once there are more than a couple of sheets open.
|
|
||||||
- The settings popover is split into four tabs (General / Format / Data /
|
|
||||||
Updates) in `index.html`, each a `.settings-tabpanel` toggled by a
|
|
||||||
`#settings-tabs .tab` button in `bindSettingsUI()` (`main.ts`) — plain
|
|
||||||
`classList.toggle`, no router. Tab styling reuses the sidebar's
|
|
||||||
`doc-item.active` idiom (`background: var(--sel)` for the active pill)
|
|
||||||
rather than introducing a new visual pattern.
|
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
|
|
@ -218,96 +119,21 @@ recursing forever.
|
||||||
deleted sheets become `.numi` files under `backups/deleted/` and are pruned
|
deleted sheets become `.numi` files under `backups/deleted/` and are pruned
|
||||||
by age. Saving is debounced at 400 ms.
|
by age. Saving is debounced at 400 ms.
|
||||||
|
|
||||||
## Platforms and packaging
|
|
||||||
|
|
||||||
- One codebase, three desktop targets. `tauri.conf.json` holds everything
|
|
||||||
shared; the per-platform bits live in `tauri.windows.conf.json`,
|
|
||||||
`tauri.linux.conf.json` and `tauri.macos.conf.json`, which Tauri merges over
|
|
||||||
the base config (JSON Merge Patch) according to the target being built. Each
|
|
||||||
one only sets `bundle.targets` and that platform's bundler options — NSIS on
|
|
||||||
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS.
|
|
||||||
- `release.yml` builds five artifacts on three runner families: Windows x64 and
|
|
||||||
arm64, Linux x64 (`ubuntu-22.04` — the oldest runner carrying webkit2gtk-4.1,
|
|
||||||
which keeps the glibc floor low), macOS arm64 and x64. Two separate macOS
|
|
||||||
targets rather than `universal-apple-darwin`: it matches how Windows already
|
|
||||||
ships per-architecture, halves each download, and gives the updater the
|
|
||||||
`darwin-aarch64`/`darwin-x86_64` keys it looks up by default.
|
|
||||||
- The whole matrix stays `max-parallel: 1`. Every job hands its assets to the
|
|
||||||
*same* GitHub Release object, and tauri-action's find-or-create check races if
|
|
||||||
two jobs reach it at once — adding platforms did not change that constraint,
|
|
||||||
it made it apply to more jobs.
|
|
||||||
- No OS code signing anywhere. Windows ships unsigned; macOS is *ad-hoc* signed
|
|
||||||
(`bundle.macOS.signingIdentity: "-"`), which needs no Apple certificate but is
|
|
||||||
required for Apple Silicon to run a downloaded binary at all — without it the
|
|
||||||
arm64 build is rejected as damaged rather than merely warned about. Gatekeeper
|
|
||||||
still asks the user to allow the app on first launch either way.
|
|
||||||
- `bundle.fileAssociations` covers `.numi`/`.sum` on Windows (NSIS registry
|
|
||||||
entries) and macOS (`CFBundleDocumentTypes`). On Linux it writes nothing
|
|
||||||
useful: registering an extension there also needs a shared-mime-info XML that
|
|
||||||
Tauri does not generate, so `.numi` files are opened by dragging them into the
|
|
||||||
window rather than from the file manager.
|
|
||||||
- `icons/icon.icns` is emitted by `scripts/gen-icon.ts` alongside the PNGs and
|
|
||||||
the `.ico`, hand-rolled the same dependency-free way: `icns` magic + total
|
|
||||||
length, then one `[OSType][length][PNG]` chunk per size, using the modern
|
|
||||||
PNG-payload tags `iconutil` produces (`icp4`/`icp5`, `ic07`–`ic14`). Rerun it
|
|
||||||
with `npm run icons` after touching the artwork.
|
|
||||||
|
|
||||||
## Auto-update
|
## Auto-update
|
||||||
|
|
||||||
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
|
- `updater.ts` calls `tauri-plugin-updater`'s `check()` on boot; if a signed
|
||||||
update is available the user is prompted, then `downloadAndInstall()` +
|
update is available the user is prompted, then `downloadAndInstall()` +
|
||||||
`tauri-plugin-process`'s `relaunch()`. `checkForUpdate()` returns
|
`tauri-plugin-process`'s `relaunch()`.
|
||||||
`AvailableUpdate | null | "error"` — `null` means the check succeeded and
|
|
||||||
found nothing newer, `"error"` means the check itself failed (offline,
|
|
||||||
signature mismatch, etc.); the distinction only matters for the manual path
|
|
||||||
below. Every outcome is now visible in devtools: failures via
|
|
||||||
`console.warn`, and a successful "no update" result logs the running
|
|
||||||
version alongside "that's the latest published release" via `console.info`
|
|
||||||
— the most common false alarm is simply that the installed build already
|
|
||||||
*is* the newest release (e.g. right after installing it), which used to be
|
|
||||||
indistinguishable from a silently broken check.
|
|
||||||
- Closing the window hides it to the tray instead of exiting the process (see
|
|
||||||
`on_window_event` in `main.rs`), so a boot-time-only check can go unnoticed
|
|
||||||
for a long time on a machine that isn't restarted often. `main.ts` also
|
|
||||||
reruns `checkUpdate()` on a `setInterval` (every 6 hours) so a long-lived
|
|
||||||
tray process still notices new releases, and Settings → Updates has a
|
|
||||||
manual "Check for updates" button (`checkUpdate(true)`) that reports
|
|
||||||
"up to date" / "check failed" via toast — those toasts are suppressed on
|
|
||||||
the automatic background checks so the app doesn't nag every few hours.
|
|
||||||
A `SettingsData.autoUpdateEnabled` toggle turns off both the boot check and
|
|
||||||
the periodic recheck (re-read from `settings` on every interval tick, so
|
|
||||||
flipping it while the app is running takes effect immediately); the manual
|
|
||||||
button ignores the toggle and always works.
|
|
||||||
- The update endpoint is `latest.json` published alongside each GitHub
|
- The update endpoint is `latest.json` published alongside each GitHub
|
||||||
Release. `release.yml` signs every platform's updater artifact with a
|
Release. `release.yml` signs both architectures with a minisign keypair:
|
||||||
minisign keypair (this is Tauri's own update signing, unrelated to OS code
|
|
||||||
signing):
|
|
||||||
the public half is embedded in `tauri.conf.json`; the private half + its
|
the public half is embedded in `tauri.conf.json`; the private half + its
|
||||||
password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets
|
password live only as `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` repo secrets
|
||||||
(write-only — there is no durable copy in the repo). Losing that key means
|
(write-only — there is no durable copy in the repo). Losing that key means
|
||||||
already-installed copies can never trust a future signed release again.
|
already-installed copies can never trust a future signed release again.
|
||||||
|
|
||||||
## Storage (cache files)
|
|
||||||
|
|
||||||
All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder):
|
|
||||||
|
|
||||||
| File | Contents | TTL |
|
|
||||||
|------|----------|-----|
|
|
||||||
| `settings.json` | app settings | persisted |
|
|
||||||
| `documents.json` | all sheets | persisted (400 ms debounce) |
|
|
||||||
| `rates.json` | live exchange rates (open.er-api.com + CoinGecko) | 1 hour |
|
|
||||||
| `market.json` | stock/commodity prices (Yahoo Finance v8 chart, one request per symbol) | 15 minutes |
|
|
||||||
| `rates-YYYY-MM-DD.json` | historical ECB rates for one date (frankfurter.dev) | permanent |
|
|
||||||
| `backups/documents-YYYY-MM-DD.json` | daily snapshots | 14 days |
|
|
||||||
| `backups/deleted/*.numi` | soft-deleted sheets | configurable |
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`npm test` runs 373 vitest cases across 21 files: `src/engine/*.test.ts` covers every
|
`npm test` runs ~96 vitest cases over the engine (`src/engine/*.test.ts`):
|
||||||
expression class, both languages, deterministic injected rates, goal seek,
|
every expression class, both languages, deterministic injected rates,
|
||||||
historical rates (injected), date-format literals/display (dedicated engines
|
plus a regression suite covering all known-fixed bugs.
|
||||||
with an explicit `dateFormat`, since the default "system" format depends on
|
UI is exercised manually; the engine is where the complexity lives.
|
||||||
the CI machine's locale), plus a regression suite covering all known-fixed
|
|
||||||
bugs; `src/workspace.test.ts` covers cross-sheet references, caching and
|
|
||||||
cycle detection. UI is exercised manually; the engine is where the complexity
|
|
||||||
lives.
|
|
||||||
|
|
|
||||||
|
|
@ -22,26 +22,18 @@ src/
|
||||||
index.ts фасад SumEngine + API расширений + итоги листа
|
index.ts фасад SumEngine + API расширений + итоги листа
|
||||||
ui/
|
ui/
|
||||||
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
|
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
|
||||||
search.ts оверлей поиска по всем листам (текст + запросы по значению)
|
|
||||||
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
|
|
||||||
sparkline.ts рендер SVG-спарклайна для результатов `chart`
|
|
||||||
app.css темы (светлая/тёмная через CSS-переменные)
|
app.css темы (светлая/тёмная через CSS-переменные)
|
||||||
main.ts запуск приложения: документы, настройки, строка статуса, dnd
|
main.ts запуск приложения: документы, настройки, строка статуса, dnd
|
||||||
workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов
|
|
||||||
storage.ts команды Tauri / запасной вариант на localStorage
|
storage.ts команды Tauri / запасной вариант на localStorage
|
||||||
extensions.ts запускает пользовательские .js-файлы в песочнице
|
extensions.ts запускает пользовательские .js-файлы через движок
|
||||||
QuickJS/WASM, мост к API numi.* движка
|
|
||||||
i18n.ts строки интерфейса (en/ru)
|
i18n.ts строки интерфейса (en/ru)
|
||||||
updater.ts проверка tauri-plugin-updater, установка + перезапуск
|
updater.ts проверка tauri-plugin-updater, установка + перезапуск
|
||||||
logger.ts тонкая обёртка над console (info/warn/error)
|
|
||||||
src-tauri/
|
src-tauri/
|
||||||
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
|
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
|
||||||
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
|
(open.er-api.com + CoinGecko) с кэшем, резервные копии,
|
||||||
с кэшем, исторические курсы (frankfurter.dev, постоянный
|
миграция папки данных, открытие файлов, регистрация
|
||||||
кэш на каждую дату), резервные копии, миграция папки
|
плагинов (autostart, global-shortcut, single-instance,
|
||||||
данных, открытие файлов, регистрация плагинов (autostart,
|
opener, dialog, updater, process)
|
||||||
global-shortcut, single-instance, opener, dialog,
|
|
||||||
updater, process)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Как строка превращается в результат
|
## Как строка превращается в результат
|
||||||
|
|
@ -97,94 +89,11 @@ src-tauri/
|
||||||
только ms/µs/ns/ps, потому что символ «аттосекунды» затенил бы слово `as`.
|
только ms/µs/ns/ps, потому что символ «аттосекунды» затенил бы слово `as`.
|
||||||
- `и`/`или` двойного назначения: `и` складывает (`5 и 3` = 8), `или` — побитовое.
|
- `и`/`или` двойного назначения: `и` складывает (`5 и 3` = 8), `или` — побитовое.
|
||||||
`от/без/на` работают только с процентом в левой части; иначе это просто текст.
|
`от/без/на` работают только с процентом в левой части; иначе это просто текст.
|
||||||
Тот же токен `на` используется как разделитель даты для исторических курсов
|
|
||||||
(`в EUR на 2024-01-01`) — `parseSeq` проверяет `pctop(on)` + `datelit`
|
|
||||||
сразу после цели конвертации валюты и поглощает оба при совпадении.
|
|
||||||
- Курсы валют — «единиц за 1 USD»; соотношение единицы валюты вычисляется при
|
- Курсы валют — «единиц за 1 USD»; соотношение единицы валюты вычисляется при
|
||||||
вычислении из текущей карты курсов, поэтому обновление курсов пересчитывает
|
токенизации из текущей карты курсов, поэтому обновление курсов пересчитывает
|
||||||
всё при следующей оценке. Исторические курсы живут отдельно в
|
всё при следующей оценке.
|
||||||
`historicalRates: Map<date, Map<code, number>>` на `SumEngine` и передаются
|
|
||||||
через `EvalCtx` в `evalConv`.
|
|
||||||
- Живые рыночные данные (акции, сырьё через Yahoo Finance) объединяются в ту же
|
|
||||||
карту курсов через `applyAllRates()` в `main.ts`, чтобы один вызов `setRates()`
|
|
||||||
не перезаписывал другой.
|
|
||||||
- Поиск неизвестного (`? * 1.2 = 1000`): `?` токенизируется как `{ t: "unknown" }`,
|
|
||||||
разбирается в узел `{ k: "unknown" }`. `parseLine` обнаруживает `unknown` + `assign`
|
|
||||||
и строит `{ k: "goalseek", lhs, rhs }`. Решатель пробует линейный метод (f(0),
|
|
||||||
f(1) → точный корень для аффинных выражений) и переходит к бисекции для нелинейных.
|
|
||||||
- `ChartValue` (`kind: "chart"`) возвращается из `evalAgg("chart", …)` и
|
|
||||||
минует `formatValue` — оверлей результатов рисует SVG-спарклайн вместо текста.
|
|
||||||
- Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции)
|
- Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции)
|
||||||
при запуске. `buildRegistry()` (`registry.ts`) — тонкий оркестратор над
|
при запуске.
|
||||||
`registerCoreVocab` / `registerUnits` / `registerAreaVolume` /
|
|
||||||
`registerCurrencies` / `registerExtraUnits` / `registerCrypto` /
|
|
||||||
`registerCompletions` — **порядок вызовов важен**: `Registry.addPhrase`
|
|
||||||
оставляет *первую* регистрацию одинаковой фразы и отбрасывает последующие
|
|
||||||
(базовый словарь обязан побеждать фразы единиц/валют, если они случайно
|
|
||||||
совпадают).
|
|
||||||
- Умножение двух величин одного измерения (`2 kg * 500 g`) конвертирует
|
|
||||||
правый операнд в единицу левого перед умножением, чтобы результат имел
|
|
||||||
осмысленную единицу (`1 kg`, а не `1000` в подписи `kg`). Умножение разных
|
|
||||||
измерений (`$12 * 3`, `5 hours * 2`) не затронуто — там правая часть
|
|
||||||
трактуется как простой числовой множитель.
|
|
||||||
- `//` начинает комментарий только в начале строки или после пробела
|
|
||||||
(`findCommentStart` в `index.ts`), поэтому URL вида `see https://example.com`
|
|
||||||
не обрезается на двойном слэше.
|
|
||||||
- **Литералы и форматы дат.** Помимо ISO (`2024-01-01`), лексер понимает
|
|
||||||
`D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) с одинаковым
|
|
||||||
разделителем с обеих сторон и 4-значным годом. Порядок день/месяц
|
|
||||||
разрешается для каждого литерала отдельно: компонент `>12` однозначно
|
|
||||||
определяет, где день; если оба `≤12`, решает настройка движка `dateFormat`.
|
|
||||||
Все три формы литералов отклоняют компоненты вне диапазона через
|
|
||||||
round-trip проверку (`validDateMs` в `lexer.ts` — `new Date(y, mo-1, d)`
|
|
||||||
молча *перекатывает* невалидные даты вроде `2026-13-45` вместо ошибки,
|
|
||||||
поэтому построенные год/месяц/день сравниваются обратно со входом).
|
|
||||||
`SumEngine` разрешает `dateFormat: "system"` в реальный порядок день/месяц
|
|
||||||
один раз через `detectDateOrder()` (`datetime.ts`, пробует
|
|
||||||
`Intl.DateTimeFormat` на фикстуре `2000-12-31`) и передаёт его в
|
|
||||||
`tokenize()` → `lexLine()` при каждом вызове; настройки `"dmy"`/`"mdy"`
|
|
||||||
пропускают детекцию. Отображение работает так же в обратную сторону:
|
|
||||||
`formatter.ts` либо использует форматирование `Intl` по локали ОС
|
|
||||||
(`"system"`), либо строит фиксированную строку `YYYY-MM-DD` / `DD.MM.YYYY` /
|
|
||||||
`MM/DD/YYYY` через `Intl.DateTimeFormat(...).formatToParts()` (это нужно,
|
|
||||||
чтобы уважать собственный IANA-часовой пояс значения, чего форматтер на
|
|
||||||
основе голого `ms` сделать не может). Экземпляры `Intl.DateTimeFormat`
|
|
||||||
кэшируются по ключу опций в `formatter.ts` (`dtf()`), так как их создание
|
|
||||||
сравнительно дорого, а каждая строка с датой пересобирает формат при
|
|
||||||
каждой отрисовке.
|
|
||||||
- У разбора названий часовых поясов (`time in Europe/Berlin`) есть две
|
|
||||||
независимые ловушки. Во-первых, шумовой фильтр `parseLine` оставляет
|
|
||||||
word-токены только сразу после `conv`-токена (`in`/`as`/...); любой другой
|
|
||||||
тип токена сбрасывает это состояние — голый `/` между двумя словами
|
|
||||||
часового пояса обычно отбросил бы второе слово раньше, чем его увидит
|
|
||||||
парсер, поэтому фильтр отдельно разрешает `/`, встреченный при сборе
|
|
||||||
post-conv слов. Во-вторых, цикл сбора слов в `parseTarget()` зеркалит то же
|
|
||||||
исключение, чтобы продолжать читать после `/`, а не останавливаться на
|
|
||||||
первом слове. Нужны оба места — исправление только одного всё равно
|
|
||||||
урезает `Europe/Berlin` до `Europe`.
|
|
||||||
|
|
||||||
## Ссылки между листами
|
|
||||||
|
|
||||||
`@Sheet.key` лексится напрямую в `lexer.ts` (не через реестр фраз — названия
|
|
||||||
листов это произвольный текст пользователя, а не словарь), давая лексему
|
|
||||||
`xref` с `{ sheet, key }`. Движок умеет только *спросить* значение:
|
|
||||||
`EvalCtx.resolveXRef?.(sheet, key)` возвращает
|
|
||||||
`{ ok: true; value } | { ok: false; reason }`; без резолвера (тесты,
|
|
||||||
расширения, `evaluateExpression`) ссылка просто не резолвится. Нерешённые
|
|
||||||
ссылки бросают `XRefError` внутри, он ловится в `SumEngine.evaluateDocument`
|
|
||||||
и превращается в `LineResult.error` (рисуется как `#ref?` в колонке
|
|
||||||
результатов) — все прочие ошибки вычисления по-прежнему тихо дают пустую
|
|
||||||
строку, это не изменилось.
|
|
||||||
|
|
||||||
Движок не знает про "другие листы" — это целиком живёт в `src/workspace.ts`.
|
|
||||||
`Workspace` читает тексты всех листов из `data.contents` приложения,
|
|
||||||
кэширует экспорт каждого листа (его присвоенные переменные, `total`, `last`)
|
|
||||||
после первого вычисления, и сбрасывает кэш листа плюс всех его транзитивных
|
|
||||||
зависимых (находятся сканированием текста на `@Name.`) при правке листа или
|
|
||||||
обновлении курсов. `Workspace.evaluateSheet(id, text)` кладёт `id` в стек
|
|
||||||
резолвинга на всё время вызова, поэтому лист, ссылающийся сам на себя —
|
|
||||||
напрямую или через другой лист — сразу натыкается на проверку "уже в
|
|
||||||
стеке" и получает `circular reference` вместо бесконечной рекурсии.
|
|
||||||
|
|
||||||
## Особенности UI
|
## Особенности UI
|
||||||
|
|
||||||
|
|
@ -196,20 +105,10 @@ src-tauri/
|
||||||
прокрутке/изменении геометрии. Клик для копирования читает `data-value`.
|
прокрутке/изменении геометрии. Клик для копирования читает `data-value`.
|
||||||
- Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа
|
- Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа
|
||||||
занимает менее миллисекунды, поэтому нет дебаунса и воркеров.
|
занимает менее миллисекунды, поэтому нет дебаунса и воркеров.
|
||||||
- Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование текста
|
- Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование
|
||||||
каждого открытого листа, а не расширение поиска CodeMirror — ему нужно
|
`data.contents` в `main.ts`, а не расширение поиска CodeMirror — ему нужно
|
||||||
пересекать документы, поэтому он не трогает активный редактор, пока не
|
пересекать документы, поэтому он не трогает активный редактор, пока не
|
||||||
кликнут результат (`editor.goToLine`). Живёт в `src/ui/search.ts`
|
кликнут результат (`editor.goToLine`).
|
||||||
(`initSearch()`), который владеет DOM `#search-overlay` и собственным
|
|
||||||
дебаунсом ввода на 150 мс — запрос по значению (`>1000`) пересчитывает
|
|
||||||
каждый открытый лист, так что дебаунс важен, как только листов становится
|
|
||||||
больше пары.
|
|
||||||
- Поповер настроек разбит на четыре вкладки (Общие / Формат / Данные /
|
|
||||||
Обновления) в `index.html`, каждая — `.settings-tabpanel`, переключаемая
|
|
||||||
кнопкой `#settings-tabs .tab` в `bindSettingsUI()` (`main.ts`) — обычный
|
|
||||||
`classList.toggle`, без роутера. Стиль вкладок повторяет идиому сайдбара
|
|
||||||
`doc-item.active` (`background: var(--sel)` для активной таблетки) вместо
|
|
||||||
нового визуального паттерна.
|
|
||||||
|
|
||||||
## Хранилище
|
## Хранилище
|
||||||
|
|
||||||
|
|
@ -220,98 +119,22 @@ src-tauri/
|
||||||
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
|
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
|
||||||
Сохранение откладывается на 400 мс.
|
Сохранение откладывается на 400 мс.
|
||||||
|
|
||||||
## Платформы и упаковка
|
|
||||||
|
|
||||||
- Одна кодовая база, три десктопных платформы. Всё общее лежит в
|
|
||||||
`tauri.conf.json`, платформенные куски — в `tauri.windows.conf.json`,
|
|
||||||
`tauri.linux.conf.json` и `tauri.macos.conf.json`, которые Tauri накладывает
|
|
||||||
поверх базового конфига (JSON Merge Patch) в зависимости от собираемой цели.
|
|
||||||
Каждый задаёт только `bundle.targets` и опции своего упаковщика: NSIS на
|
|
||||||
Windows, deb/rpm/AppImage на Linux, .app/.dmg на macOS.
|
|
||||||
- `release.yml` собирает пять артефактов на трёх видах раннеров: Windows x64 и
|
|
||||||
arm64, Linux x64 (`ubuntu-22.04` — самый старый раннер с webkit2gtk-4.1, что
|
|
||||||
держит планку glibc низко), macOS arm64 и x64. Две отдельные macOS-цели вместо
|
|
||||||
`universal-apple-darwin`: так же, как Windows уже собирается по архитектурам,
|
|
||||||
вдвое меньше вес загрузки, и апдейтер получает ключи `darwin-aarch64` /
|
|
||||||
`darwin-x86_64`, которые ищет по умолчанию.
|
|
||||||
- Вся матрица остаётся `max-parallel: 1`. Все джобы отдают артефакты в *один и
|
|
||||||
тот же* объект GitHub Release, а проверка find-or-create в tauri-action гонит,
|
|
||||||
если две джобы доходят до неё одновременно — добавление платформ это
|
|
||||||
ограничение не сняло, а распространило на большее число джоб.
|
|
||||||
- Подписи ОС нет нигде. Windows выходит неподписанным; macOS подписан *ad-hoc*
|
|
||||||
(`bundle.macOS.signingIdentity: "-"`) — сертификат Apple для этого не нужен, но
|
|
||||||
без него Apple Silicon вообще откажется запускать скачанный бинарник (сочтёт
|
|
||||||
повреждённым, а не просто предупредит). Gatekeeper в любом случае попросит
|
|
||||||
пользователя разрешить приложение при первом запуске.
|
|
||||||
- `bundle.fileAssociations` покрывает `.numi`/`.sum` на Windows (записи в реестр
|
|
||||||
из NSIS) и macOS (`CFBundleDocumentTypes`). На Linux он не даёт ничего
|
|
||||||
полезного: там для регистрации расширения нужен ещё XML для shared-mime-info,
|
|
||||||
который Tauri не генерирует, так что `.numi` открываются перетаскиванием в
|
|
||||||
окно, а не из файлового менеджера.
|
|
||||||
- `icons/icon.icns` создаёт `scripts/gen-icon.ts` рядом с PNG и `.ico`, вручную
|
|
||||||
и без зависимостей: магия `icns` + общая длина, дальше по чанку
|
|
||||||
`[OSType][длина][PNG]` на размер, с современными PNG-тегами, которые выдаёт
|
|
||||||
`iconutil` (`icp4`/`icp5`, `ic07`–`ic14`). После правки картинки перегенерить
|
|
||||||
через `npm run icons`.
|
|
||||||
|
|
||||||
## Автообновление
|
## Автообновление
|
||||||
|
|
||||||
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
|
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
|
||||||
подписанное обновление доступно, пользователю предлагают установить, затем
|
подписанное обновление доступно, пользователю предлагают установить, затем
|
||||||
`downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`.
|
`downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`.
|
||||||
`checkForUpdate()` возвращает `AvailableUpdate | null | "error"` — `null`
|
|
||||||
значит проверка прошла успешно и ничего новее нет, `"error"` значит сама
|
|
||||||
проверка не удалась (офлайн, рассинхрон подписи и т.п.); различие важно
|
|
||||||
только для ручного пути ниже. Теперь виден любой исход: сбои через
|
|
||||||
`console.warn`, а успешный результат «нет обновления» логирует текущую
|
|
||||||
версию рядом с «это последний опубликованный релиз» через `console.info` —
|
|
||||||
самый частый ложный сигнал тревоги это когда установленная сборка И ЕСТЬ
|
|
||||||
последний релиз (например, сразу после его установки), что раньше было
|
|
||||||
неотличимо от молча сломанной проверки.
|
|
||||||
- Закрытие окна прячет его в трей вместо завершения процесса (см.
|
|
||||||
`on_window_event` в `main.rs`), поэтому проверка только при старте может
|
|
||||||
долго оставаться незамеченной на машине, которую редко перезагружают.
|
|
||||||
`main.ts` также перезапускает `checkUpdate()` через `setInterval` (каждые
|
|
||||||
6 часов), чтобы долгоживущий трей-процесс всё равно замечал новые релизы,
|
|
||||||
а в настройках (вкладка «Обновления») есть кнопка «Проверить обновления»
|
|
||||||
(`checkUpdate(true)`), которая сообщает «последняя версия» / «не удалось
|
|
||||||
проверить» через toast — эти уведомления подавлены на автоматических
|
|
||||||
фоновых проверках, чтобы приложение не напоминало о себе каждые несколько
|
|
||||||
часов. Тумблер `SettingsData.autoUpdateEnabled` отключает и проверку при
|
|
||||||
запуске, и периодическую (значение перечитывается из `settings` на каждом
|
|
||||||
тике интервала, так что переключение прямо во время работы срабатывает
|
|
||||||
сразу); ручная кнопка тумблер игнорирует и работает всегда.
|
|
||||||
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
|
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
|
||||||
Release. `release.yml` подписывает артефакт обновления каждой платформы парой
|
Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
|
||||||
ключей minisign (это собственная подпись обновлений Tauri, к подписи кода в ОС
|
|
||||||
отношения не имеет):
|
|
||||||
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
|
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
|
||||||
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
|
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
|
||||||
(write-only — в репозитории нет постоянной копии). Потеря этого ключа
|
(write-only — в репозитории нет постоянной копии). Потеря этого ключа
|
||||||
означает, что уже установленные копии никогда больше не смогут доверять
|
означает, что уже установленные копии никогда больше не смогут доверять
|
||||||
будущему подписанному релизу.
|
будущему подписанному релизу.
|
||||||
|
|
||||||
## Хранилище (кэш-файлы)
|
|
||||||
|
|
||||||
Все файлы ниже живут в `%APPDATA%/app.summarum.calc` (или в папке данных пользователя):
|
|
||||||
|
|
||||||
| Файл | Содержимое | TTL |
|
|
||||||
|------|-----------|-----|
|
|
||||||
| `settings.json` | настройки | постоянно |
|
|
||||||
| `documents.json` | все листы | постоянно (дебаунс 400 мс) |
|
|
||||||
| `rates.json` | живые курсы валют (open.er-api.com + CoinGecko) | 1 час |
|
|
||||||
| `market.json` | котировки акций/сырья (Yahoo Finance v8 chart, по запросу на символ) | 15 минут |
|
|
||||||
| `rates-YYYY-MM-DD.json` | исторические курсы ЕЦБ за одну дату (frankfurter.dev) | навсегда |
|
|
||||||
| `backups/documents-YYYY-MM-DD.json` | ежедневные снимки | 14 дней |
|
|
||||||
| `backups/deleted/*.numi` | мягко удалённые листы | настраивается |
|
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все
|
`npm test` запускает ~96 vitest-кейсов по движку (`src/engine/*.test.ts`):
|
||||||
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
|
все классы выражений, оба языка, детерминированные инжектированные курсы,
|
||||||
неизвестного, исторические курсы (инжектированные), литералы и отображение
|
плюс набор регрессий по всем известным исправленным багам.
|
||||||
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
|
UI проверяется вручную; движок — там где живёт вся сложность.
|
||||||
умолчанию зависит от локали машины CI), плюс набор регрессий по всем известным
|
|
||||||
исправленным багам; `src/workspace.test.ts` покрывает межлистовые ссылки,
|
|
||||||
кэширование и обнаружение циклов. UI проверяется вручную; движок — там где
|
|
||||||
живёт вся сложность.
|
|
||||||
|
|
|
||||||
102
README.md
102
README.md
|
|
@ -17,11 +17,7 @@ total → $1,066.97
|
||||||
$2000 in rubles → 144,060 ₽
|
$2000 in rubles → 144,060 ₽
|
||||||
20% off 1500 → 1,200
|
20% off 1500 → 1,200
|
||||||
today + 6 weeks → 22 July 2026
|
today + 6 weeks → 22 July 2026
|
||||||
1000 USD in EUR on 2024-01-01 → € 917.30
|
time in Tokyo → 14:25
|
||||||
? * 1.2 = 1000 → 833.33
|
|
||||||
AAPL in USD → $213.55
|
|
||||||
days until 2027-01-01 → 183 day
|
|
||||||
chart → ▂▄▇█▆
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Works in **English and Russian** at the same time: `5 метров в см`, `20$ в евро`,
|
Works in **English and Russian** at the same time: `5 метров в см`, `20$ в евро`,
|
||||||
|
|
@ -29,37 +25,23 @@ Works in **English and Russian** at the same time: `5 метров в см`, `20
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Grab the build for your system from [Releases](../../releases). The whole app
|
Grab the latest `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows
|
||||||
is about 2 MB and checks for updates on its own after that.
|
on ARM) from [Releases](../../releases), run it, done. The whole app is
|
||||||
|
about 2 MB. It checks for updates on its own after that.
|
||||||
- **Windows** — `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows on
|
|
||||||
ARM). Run it, done.
|
|
||||||
- **Linux** — `.AppImage` (`chmod +x`, then run) or the `.deb`/`.rpm` if you'd
|
|
||||||
rather install it properly. Self-updating only works on the AppImage; deb and
|
|
||||||
rpm are updated by downloading the next one.
|
|
||||||
- **macOS** — `.dmg`, Apple Silicon or Intel. These builds are not signed with
|
|
||||||
an Apple Developer certificate, so the first launch has to be a right-click →
|
|
||||||
**Open** rather than a double-click (or run
|
|
||||||
`xattr -cr /Applications/Summarum.app` once in a terminal). Every launch after
|
|
||||||
that is normal.
|
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- **Windows 10 (1803+) or Windows 11**, 64-bit, plus the
|
- **Windows 10 (1803+) or Windows 11**, 64-bit.
|
||||||
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
- **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
||||||
already part of Windows 11 and most updated Windows 10 systems; if it's
|
already part of Windows 11 and most updated Windows 10 systems; if it's
|
||||||
missing, the installer downloads it automatically.
|
missing, the installer downloads it automatically.
|
||||||
- **Linux** with `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) and
|
- Internet connection is only needed for live currency rates — everything
|
||||||
`libayatana-appindicator3` for the tray icon.
|
|
||||||
- **macOS 10.13** or newer.
|
|
||||||
- Internet connection is only needed for live rates and market data — everything
|
|
||||||
else works offline (rates fall back to a cached/bundled snapshot).
|
else works offline (rates fall back to a cached/bundled snapshot).
|
||||||
|
|
||||||
## What it understands
|
## What it understands
|
||||||
|
|
||||||
**Plain math** — `2 + 2`, `8 / (45 - 20%)`, parentheses, `^`, `sqrt`, `sin`,
|
**Plain math** — `2 + 2`, `8 / (45 - 20%)`, parentheses, `^`, `sqrt`, `sin`,
|
||||||
factorials, `random()`, `random(1; 6)`. Decimal-exact: `0.1 + 0.2` is `0.3`,
|
factorials. Decimal-exact: `0.1 + 0.2` is `0.3`, not `0.30000000000000004`.
|
||||||
not `0.30000000000000004`.
|
|
||||||
|
|
||||||
**Words as operators** — `12 plus 4`, `20 divided by 4`, `5 и 3`. Text around
|
**Words as operators** — `12 plus 4`, `20 divided by 4`, `5 и 3`. Text around
|
||||||
the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
|
the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
|
||||||
|
|
@ -76,74 +58,43 @@ the math is ignored, so `spent 20 on pizza and 5 on coffee` just gives 25.
|
||||||
(refreshed hourly, cached for offline). `$100 + 20%`, `5 eth in USD`,
|
(refreshed hourly, cached for offline). `$100 + 20%`, `5 eth in USD`,
|
||||||
`9800 рублей в долларах`.
|
`9800 рублей в долларах`.
|
||||||
|
|
||||||
**Live market data** — stock and commodity prices refreshed every 15 minutes:
|
|
||||||
`100 AAPL in USD`, `2 GC=F in USD` (gold futures), `MSFT + GOOGL`.
|
|
||||||
|
|
||||||
**Historical exchange rates** — `1000 USD in EUR on 2024-01-01` looks up the
|
|
||||||
ECB rate for that exact date (via frankfurter.dev) and caches it permanently.
|
|
||||||
|
|
||||||
**Percents the human way** — `20% of 80`, `20% off 1500`, `5 as a % of 25`,
|
**Percents the human way** — `20% of 80`, `20% off 1500`, `5 as a % of 25`,
|
||||||
`20% of what is 30`.
|
`20% of what is 30`.
|
||||||
|
|
||||||
**Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York`
|
**Dates and time** — `today + 2 weeks`, `tomorrow - today`, `time in New York`,
|
||||||
(also works with a raw IANA id: `time in Europe/Berlin`), `now in unix`,
|
`now in unix`, `1750000000 as date`.
|
||||||
`1750000000 as date`. Date literals: ISO (`2024-01-01 + 30 days`), or
|
|
||||||
dot/slash with a 4-digit year — `31.12.2024`, `12/31/2024` — where the
|
|
||||||
day/month order for ambiguous cases follows the Date format setting.
|
|
||||||
`days until 2027-01-01`, `days since 2020-03-15`.
|
|
||||||
|
|
||||||
**Goal seek** — put `?` where the unknown is and an `=` with the target:
|
|
||||||
`? * 1.2 = 1000` → `833.33`, `100 - ? = 30` → `70`. Works for any expression
|
|
||||||
solvable by linear probe or bisection.
|
|
||||||
|
|
||||||
**Programmer stuff** — `0xFF & 0x0F`, `1 << 4`, `255 in hex`, `in binary`,
|
**Programmer stuff** — `0xFF & 0x0F`, `1 << 4`, `255 in hex`, `in binary`,
|
||||||
plus `in fraction` (0.75 → 3/4) and `in roman` (2026 → MMXXVI).
|
plus `in fraction` (0.75 → 3/4) and `in roman` (2026 → MMXXVI).
|
||||||
|
|
||||||
**Sheets that calculate** — variables (`rent = $500`), running totals
|
**Sheets that calculate** — variables (`rent = $500`), running totals
|
||||||
(`sum`, `avg`, `prev`, `count`, `min`, `max`, `product`), `#` headers,
|
(`sum`, `avg`, `prev`), `#` headers, `//` comments. The status bar always shows
|
||||||
`//` comments. The status bar always shows the sheet total; select a few lines
|
the sheet total; select a few lines to see just their sum.
|
||||||
to see just their sum. Add `chart` at the end of a block to draw a sparkline.
|
|
||||||
|
|
||||||
**Cross-sheet references** — pull a value from another sheet with `@`:
|
|
||||||
`@Budget.total`, `@Budget.rent`, `@[Trip to Lisbon].food`. A sheet made of
|
|
||||||
nothing but references becomes a live dashboard:
|
|
||||||
`@Assets.total - @Debts.total`. Give the source sheet a fixed name (not an
|
|
||||||
auto-derived one) so the reference doesn't break when its first line changes.
|
|
||||||
|
|
||||||
## Handy things
|
## Handy things
|
||||||
|
|
||||||
- **Ctrl+Alt+N** (configurable — the settings field records keys you press)
|
- **Ctrl+Alt+N** (configurable — the settings field records keys you press)
|
||||||
shows/hides the window from anywhere. Closing hides to tray.
|
shows/hides the window from anywhere. Closing hides to tray.
|
||||||
- **Ctrl+W** closes the current sheet (asks first if it has content).
|
- **Ctrl+F** (or the 🔍 button) searches every sheet at once, not just the
|
||||||
- **Ctrl+F** (or the 🔍 button) searches every sheet at once — by text or by
|
one you're in — click a hit to jump straight to that line.
|
||||||
result value: `>1000`, `<50`, `=100`, `~100` (±1%). Click a hit to jump there.
|
|
||||||
- **Click a result** to copy it. Copying a line copies it *with* the answer
|
- **Click a result** to copy it. Copying a line copies it *with* the answer
|
||||||
(`rent * 3 = $1,500`). **Ctrl+Shift+C** copies the whole sheet with answers.
|
(`rent * 3 = $1,500`). **Ctrl+Shift+C** copies the whole sheet with answers.
|
||||||
- **Export as image** — the ⤓ menu includes "Copy as image": renders the sheet
|
|
||||||
to a PNG and puts it on the clipboard (falls back to file save if the browser
|
|
||||||
blocks clipboard write).
|
|
||||||
- **Drag the divider** to resize the results column.
|
- **Drag the divider** to resize the results column.
|
||||||
- **Drop a `.numi`, `.sum`, `.txt` or `.md` file** into the window to open it
|
- **Drop a `.numi`, `.sum`, `.txt` or `.md` file** into the window to open it
|
||||||
as a sheet.
|
as a sheet.
|
||||||
- **Pin and reorder sheets** — click the pin icon on any sheet in the sidebar
|
|
||||||
to keep it at the top; use ↑↓ to rearrange.
|
|
||||||
- **Backups are automatic**: a daily snapshot of all sheets (last 14 kept), and
|
- **Backups are automatic**: a daily snapshot of all sheets (last 14 kept), and
|
||||||
deleted sheets sit in a bin for 14 days (configurable) — restore one by
|
deleted sheets sit in a bin for 14 days (configurable) — restore one by
|
||||||
dragging the file back in. Settings → Backups opens the folder.
|
dragging the file back in. Settings → Backups opens the folder.
|
||||||
- **Data folder is configurable** — point it at OneDrive/Dropbox and your
|
- **Data folder is configurable** — point it at OneDrive/Dropbox and your
|
||||||
sheets follow you between machines.
|
sheets follow you between machines.
|
||||||
- **Always on top** (Settings) pins the window above everything else.
|
- **Always on top** (Settings) pins the window above everything else.
|
||||||
- The app checks for updates on launch and every few hours after that, and
|
- The app checks for updates on launch and offers to install them — no
|
||||||
offers to install them — no manual download-and-reinstall. Settings →
|
manual download-and-reinstall.
|
||||||
Updates → "Check for updates" runs one on demand, or turn automatic
|
- Light/dark theme, en/ru interface, configurable precision and separators.
|
||||||
checking off entirely with the toggle next to it.
|
|
||||||
- Light/dark theme, en/ru interface, configurable precision, separators, and
|
|
||||||
date format (system locale, `31.12.2024`, `12/31/2024`, or `2024-12-31`).
|
|
||||||
Settings is split into General / Format / Data / Updates tabs.
|
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
> **Note:** extensions run inside a WebAssembly sandbox — no file, network or app access, only the `numi` API below. A script that hangs is cut off; a script that fails is skipped.
|
> **Note:** extensions run as trusted local scripts with access to your files. Only load code you wrote or fully trust.
|
||||||
|
|
||||||
Drop a `.js` file into the extensions folder (Settings → Extensions folder)
|
Drop a `.js` file into the extensions folder (Settings → Extensions folder)
|
||||||
and restart. The API is compatible with Numi extensions:
|
and restart. The API is compatible with Numi extensions:
|
||||||
|
|
@ -169,7 +120,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # engine tests (373 cases)
|
npm test # engine tests
|
||||||
npm run tauri dev # run the app
|
npm run tauri dev # run the app
|
||||||
npm run tauri build # build the installer
|
npm run tauri build # build the installer
|
||||||
```
|
```
|
||||||
|
|
@ -178,28 +129,19 @@ To build from source you need:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
|
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
|
||||||
Node's built-in TypeScript support)
|
Node's built-in TypeScript support)
|
||||||
- [Rust](https://rustup.rs/), stable
|
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
|
||||||
- On Windows: the MSVC toolchain plus
|
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
|
||||||
with the "Desktop development with C++" workload (the MSVC linker)
|
with the "Desktop development with C++" workload (the MSVC linker)
|
||||||
- On Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
|
|
||||||
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` and a C toolchain
|
|
||||||
- On macOS: Xcode Command Line Tools
|
|
||||||
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
|
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
|
||||||
if anything refuses to compile
|
if anything refuses to compile
|
||||||
|
|
||||||
`npm run tauri build` produces whatever the host platform bundles — NSIS on
|
|
||||||
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS. Cross-compiling
|
|
||||||
between the three is not supported; the release workflow builds each on its
|
|
||||||
own runner.
|
|
||||||
|
|
||||||
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
|
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
|
||||||
if you're on macOS, buy it. Summarum is an independent project, built from
|
if you're on macOS, buy it. Summarum is an independent project, built from
|
||||||
scratch for Windows, Linux and macOS.
|
scratch for Windows.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
98
README.ru.md
98
README.ru.md
|
|
@ -16,47 +16,28 @@
|
||||||
$2000 в рублях → 144,060 ₽
|
$2000 в рублях → 144,060 ₽
|
||||||
скидка 20% от 1500 → 1,200
|
скидка 20% от 1500 → 1,200
|
||||||
сегодня + 6 недель → 22 Июля 2026
|
сегодня + 6 недель → 22 Июля 2026
|
||||||
1000 USD в EUR на 2024-01-01 → € 917.30
|
время в Токио → 14:25
|
||||||
? * 1.2 = 1000 → 833.33
|
|
||||||
AAPL в USD → $213.55
|
|
||||||
дней до 2027-01-01 → 183 day
|
|
||||||
график → ▂▄▇█▆
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Работает на **английском и русском одновременно**: `5 метров в см`, `20$ в евро`, `сумма` — переключать язык не нужно.
|
Работает на **английском и русском одновременно**: `5 метров в см`, `20$ в евро`, `сумма` — переключать язык не нужно.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
Скачай сборку под свою систему из [Releases](../../releases). Приложение весит
|
Скачай последний `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on ARM) из [Releases](../../releases), запусти — готово. Весь установщик около 2 МБ. Дальше обновления проверяются сами.
|
||||||
около 2 МБ, дальше обновления проверяются сами.
|
|
||||||
|
|
||||||
- **Windows** — `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on
|
|
||||||
ARM). Запусти — готово.
|
|
||||||
- **Linux** — `.AppImage` (`chmod +x` и запускай) либо `.deb`/`.rpm`, если хочется
|
|
||||||
поставить нормальным пакетом. Самообновление работает только у AppImage, deb и
|
|
||||||
rpm обновляются скачиванием следующей версии.
|
|
||||||
- **macOS** — `.dmg` для Apple Silicon или Intel. Сборки не подписаны сертификатом
|
|
||||||
Apple Developer, поэтому первый запуск — правый клик → **Открыть**, а не двойной
|
|
||||||
клик (либо один раз выполнить `xattr -cr /Applications/Summarum.app` в терминале).
|
|
||||||
Дальше запускается как обычно.
|
|
||||||
|
|
||||||
Требования:
|
Требования:
|
||||||
|
|
||||||
- **Windows 10 (1803+) или Windows 11**, 64-бит, и
|
- **Windows 10 (1803+) или Windows 11**, 64-бит.
|
||||||
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
- **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
|
||||||
уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует,
|
уже встроен в Windows 11 и большинство обновлённых Windows 10; если отсутствует,
|
||||||
установщик скачает его автоматически.
|
установщик скачает его автоматически.
|
||||||
- **Linux** с `webkit2gtk-4.1` (Ubuntu 22.04+, Debian 12+, Fedora 36+) и
|
- Интернет нужен только для живых курсов валют — всё остальное работает офлайн
|
||||||
`libayatana-appindicator3` для иконки в трее.
|
|
||||||
- **macOS 10.13** или новее.
|
|
||||||
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
|
|
||||||
(курсы берутся из кэша или встроенного снимка).
|
(курсы берутся из кэша или встроенного снимка).
|
||||||
|
|
||||||
## Что понимает
|
## Что понимает
|
||||||
|
|
||||||
**Обычная математика** — `2 + 2`, `8 / (45 - 20%)`, скобки, `^`, `sqrt`, `sin`,
|
**Обычная математика** — `2 + 2`, `8 / (45 - 20%)`, скобки, `^`, `sqrt`, `sin`,
|
||||||
факториалы, `случайное()`, `случайное(1; 6)`. Точная десятичная арифметика:
|
факториалы. Точная десятичная арифметика: `0.1 + 0.2` это `0.3`, а не `0.30000000000000004`.
|
||||||
`0.1 + 0.2` это `0.3`, а не `0.30000000000000004`.
|
|
||||||
|
|
||||||
**Слова как операторы** — `12 плюс 4`, `20 делить на 4`, `5 и 3`. Текст вокруг
|
**Слова как операторы** — `12 плюс 4`, `20 делить на 4`, `5 и 3`. Текст вокруг
|
||||||
математики игнорируется: `потратил 20 на пиццу и 5 на кофе` даст 25.
|
математики игнорируется: `потратил 20 на пиццу и 5 на кофе` даст 25.
|
||||||
|
|
@ -73,73 +54,42 @@ AAPL в USD → $213.55
|
||||||
(обновляются раз в час, кэшируются для офлайна). `$100 + 20%`, `5 eth в USD`,
|
(обновляются раз в час, кэшируются для офлайна). `$100 + 20%`, `5 eth в USD`,
|
||||||
`9800 рублей в долларах`.
|
`9800 рублей в долларах`.
|
||||||
|
|
||||||
**Живые рыночные данные** — котировки акций и сырья обновляются каждые 15 минут:
|
|
||||||
`100 AAPL в USD`, `2 GC=F в USD` (фьючерс на золото), `MSFT + GOOGL`.
|
|
||||||
|
|
||||||
**Исторические курсы** — `1000 USD в EUR на 2024-01-01` запрашивает курс ЕЦБ
|
|
||||||
за этот день (через frankfurter.dev) и кэширует его навсегда.
|
|
||||||
|
|
||||||
**Проценты по-человечески** — `20% от 80`, `скидка 20% от 1500`, `5 это сколько % от 25`,
|
**Проценты по-человечески** — `20% от 80`, `скидка 20% от 1500`, `5 это сколько % от 25`,
|
||||||
`20% от какого числа равно 30`.
|
`20% от какого числа равно 30`.
|
||||||
|
|
||||||
**Даты и время** — `сегодня + 2 недели`, `завтра - сегодня`, `время в Нью-Йорке`
|
**Даты и время** — `сегодня + 2 недели`, `завтра - сегодня`, `время в Нью-Йорке`,
|
||||||
(работает и с прямым IANA-id: `время в Europe/Berlin`), `сейчас в unix`,
|
`сейчас в unix`, `1750000000 как дата`.
|
||||||
`1750000000 как дата`. Форматы дат: ISO (`2024-01-01 + 30 дней`), или через
|
|
||||||
точку/слэш с 4-значным годом — `31.12.2024`, `12/31/2024` — порядок день/месяц
|
|
||||||
для неоднозначных случаев берётся из настройки «Формат даты».
|
|
||||||
`дней до 2027-01-01`, `дней с 2020-03-15`.
|
|
||||||
|
|
||||||
**Поиск неизвестного** — поставь `?` на место неизвестного и `=` с нужным результатом:
|
|
||||||
`? * 1.2 = 1000` → `833.33`, `100 - ? = 30` → `70`. Работает для любых выражений,
|
|
||||||
решаемых линейным методом или бисекцией.
|
|
||||||
|
|
||||||
**Для программистов** — `0xFF & 0x0F`, `1 << 4`, `255 в hex`, `в binary`,
|
**Для программистов** — `0xFF & 0x0F`, `1 << 4`, `255 в hex`, `в binary`,
|
||||||
плюс `в дроби` (0.75 → 3/4) и `в roman` (2026 → MMXXVI).
|
плюс `в дроби` (0.75 → 3/4) и `в roman` (2026 → MMXXVI).
|
||||||
|
|
||||||
**Листы с переменными** — переменные (`аренда = $500`), нарастающие итоги
|
**Листы с переменными** — переменные (`аренда = $500`), нарастающие итоги
|
||||||
(`сумма`, `avg`, `prev`, `количество`, `минимум`, `максимум`, `произведение`),
|
(`сумма`, `avg`, `prev`), заголовки `#`, комментарии `//`. Строка статуса всегда
|
||||||
заголовки `#`, комментарии `//`. Строка статуса всегда показывает итог листа;
|
показывает итог листа; выдели несколько строк — увидишь их сумму.
|
||||||
выдели несколько строк — увидишь их сумму. Добавь `график` в конце блока чтобы
|
|
||||||
нарисовать спарклайн.
|
|
||||||
|
|
||||||
**Ссылки между листами** — тяни значение из другого листа через `@`:
|
|
||||||
`@Бюджет.total`, `@Бюджет.аренда`, `@[Поездка в Лиссабон].еда`. Лист из одних
|
|
||||||
ссылок становится живым дашбордом: `@Активы.total - @Долги.total`. Дай листу-
|
|
||||||
источнику постоянное имя (не автоматическое), иначе ссылка сломается при
|
|
||||||
правке первой строки.
|
|
||||||
|
|
||||||
## Удобные мелочи
|
## Удобные мелочи
|
||||||
|
|
||||||
- **Ctrl+Alt+N** (настраивается — поле в настройках записывает нажатые клавиши)
|
- **Ctrl+Alt+N** (настраивается — поле в настройках записывает нажатые клавиши)
|
||||||
показывает/скрывает окно из любого места. Закрытие прячет в трей.
|
показывает/скрывает окно из любого места. Закрытие прячет в трей.
|
||||||
- **Ctrl+W** закрывает текущий лист (спрашивает подтверждение если есть содержимое).
|
- **Ctrl+F** (или кнопка 🔍) ищет сразу по всем листам, а не только по текущему —
|
||||||
- **Ctrl+F** (или кнопка 🔍) ищет сразу по всем листам — по тексту или по значению
|
клик по результату переносит прямо на нужную строку.
|
||||||
результата: `>1000`, `<50`, `=100`, `~100` (±1%). Клик по результату — переходишь туда.
|
|
||||||
- **Клик по результату** копирует его. Копирование строки копирует её *вместе с ответом*
|
- **Клик по результату** копирует его. Копирование строки копирует её *вместе с ответом*
|
||||||
(`аренда * 3 = $1,500`). **Ctrl+Shift+C** копирует весь лист с ответами.
|
(`аренда * 3 = $1,500`). **Ctrl+Shift+C** копирует весь лист с ответами.
|
||||||
- **Экспорт картинкой** — меню ⤓ содержит «Скопировать картинкой»: рендерит лист
|
|
||||||
в PNG и кладёт в буфер обмена (при ошибке предлагает сохранить файл).
|
|
||||||
- **Потяни разделитель** чтобы изменить ширину колонки результатов.
|
- **Потяни разделитель** чтобы изменить ширину колонки результатов.
|
||||||
- **Брось файл `.numi`, `.sum`, `.txt` или `.md`** в окно — откроется как лист.
|
- **Брось файл `.numi`, `.sum`, `.txt` или `.md`** в окно — откроется как лист.
|
||||||
- **Закрепление и порядок листов** — нажми значок булавки на любом листе в сайдбаре
|
|
||||||
чтобы закрепить его вверху; кнопки ↑↓ меняют порядок.
|
|
||||||
- **Резервные копии автоматические**: ежедневный снимок всех листов (хранятся 14 дней),
|
- **Резервные копии автоматические**: ежедневный снимок всех листов (хранятся 14 дней),
|
||||||
удалённые листы лежат в корзине 14 дней (настраивается) — верни перетащив обратно.
|
удалённые листы лежат в корзине 14 дней (настраивается) — верни перетащив обратно.
|
||||||
Настройки → Backups откроет папку.
|
Настройки → Backups откроет папку.
|
||||||
- **Папка данных настраивается** — укажи OneDrive/Dropbox и листы синхронизируются
|
- **Папка данных настраивается** — укажи OneDrive/Dropbox и листы синхронизируются
|
||||||
между машинами.
|
между машинами.
|
||||||
- **Поверх всех окон** (в настройках) закрепляет окно над остальными.
|
- **Поверх всех окон** (в настройках) закрепляет окно над остальными.
|
||||||
- Приложение проверяет обновления при запуске и потом каждые несколько
|
- Приложение само проверяет обновления при запуске и предлагает установить —
|
||||||
часов, предлагая установить — вручную скачивать и переустанавливать не
|
вручную скачивать и переустанавливать не нужно.
|
||||||
нужно. Настройки → Обновления → «Проверить обновления» запускает проверку
|
- Светлая/тёмная тема, интерфейс en/ru, настраиваемая точность и разделители.
|
||||||
вручную, либо отключи автопроверку целиком тумблером рядом.
|
|
||||||
- Светлая/тёмная тема, интерфейс en/ru, настраиваемая точность, разделители
|
|
||||||
и формат даты (системный, `31.12.2024`, `12/31/2024` или `2024-12-31`).
|
|
||||||
Настройки разбиты на вкладки: Общие / Формат / Данные / Обновления.
|
|
||||||
|
|
||||||
## Расширения
|
## Расширения
|
||||||
|
|
||||||
> **Внимание:** расширения выполняются в WebAssembly-песочнице — без доступа к файлам, сети и приложению, только API `numi` ниже. Зависший скрипт прерывается, упавший — пропускается.
|
> **Внимание:** расширения запускаются как доверенные локальные скрипты с доступом к вашим файлам. Загружайте только код, который вы написали сами или полностью доверяете.
|
||||||
|
|
||||||
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
|
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
|
||||||
API совместим с расширениями Numi:
|
API совместим с расширениями Numi:
|
||||||
|
|
@ -165,7 +115,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test # тесты движка (373 кейса)
|
npm test # тесты движка
|
||||||
npm run tauri dev # запустить приложение
|
npm run tauri dev # запустить приложение
|
||||||
npm run tauri build # собрать установщик
|
npm run tauri build # собрать установщик
|
||||||
```
|
```
|
||||||
|
|
@ -173,25 +123,17 @@ npm run tauri build # собрать установщик
|
||||||
Для сборки нужны:
|
Для сборки нужны:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/) 22.6 или новее
|
- [Node.js](https://nodejs.org/) 22.6 или новее
|
||||||
- [Rust](https://rustup.rs/), stable
|
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
|
||||||
- На Windows: toolchain MSVC и
|
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||||
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
|
||||||
с компонентом «Desktop development with C++» (нужен линковщик MSVC)
|
с компонентом «Desktop development with C++» (нужен линковщик MSVC)
|
||||||
- На Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
|
|
||||||
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` и компилятор C
|
|
||||||
- На macOS: Xcode Command Line Tools
|
|
||||||
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
|
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
|
||||||
|
|
||||||
`npm run tauri build` собирает то, что умеет хост-платформа: NSIS на Windows,
|
|
||||||
deb/rpm/AppImage на Linux, .app/.dmg на macOS. Кросс-компиляция между ними не
|
|
||||||
поддерживается — релизный workflow собирает каждую платформу на своём раннере.
|
|
||||||
|
|
||||||
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
|
||||||
|
|
||||||
## Благодарность
|
## Благодарность
|
||||||
|
|
||||||
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
|
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
|
||||||
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows, Linux и macOS.
|
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|
|
||||||
102
index.html
102
index.html
|
|
@ -9,33 +9,31 @@
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<aside id="sidebar" class="hidden">
|
<aside id="sidebar" class="hidden">
|
||||||
<div id="doc-list"></div>
|
<div id="doc-list"></div>
|
||||||
<button id="new-doc" title="New document" aria-label="New document">+</button>
|
<button id="new-doc" title="New document">+</button>
|
||||||
</aside>
|
</aside>
|
||||||
<main id="content">
|
<main id="content">
|
||||||
<div id="titlebar" data-tauri-drag-region>
|
<div id="titlebar" data-tauri-drag-region>
|
||||||
<button id="toggle-sidebar" title="Documents (Ctrl+B)" aria-label="Documents">☰</button>
|
<button id="toggle-sidebar" title="Documents (Ctrl+B)">☰</button>
|
||||||
<input id="doc-title" spellcheck="false" autocomplete="off" aria-label="Document title" />
|
<input id="doc-title" spellcheck="false" autocomplete="off" />
|
||||||
<span class="spacer" data-tauri-drag-region></span>
|
<span class="spacer" data-tauri-drag-region></span>
|
||||||
<button id="open-search" title="Search all sheets (Ctrl+F)" aria-label="Search all sheets">🔍︎</button>
|
<button id="open-search" title="Search all sheets (Ctrl+F)">🔍︎</button>
|
||||||
<button id="open-settings" title="Settings" aria-label="Settings">⚙</button>
|
<button id="open-settings" title="Settings">⚙</button>
|
||||||
<div id="win-controls">
|
<div id="win-controls">
|
||||||
<button id="win-min" title="Minimize" aria-label="Minimize">─</button>
|
<button id="win-min" title="Minimize">─</button>
|
||||||
<button id="win-close" title="Hide to tray" aria-label="Hide to tray">✕</button>
|
<button id="win-close" title="Hide to tray">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor-wrap">
|
<div id="editor-wrap">
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
<div id="results"></div>
|
<div id="results"></div>
|
||||||
<div id="col-divider" title="Drag to resize" aria-label="Drag to resize results column"></div>
|
<div id="col-divider" title="Drag to resize"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="statusbar">
|
<div id="statusbar">
|
||||||
<span id="rates-info" title=""></span>
|
<span id="rates-info" title=""></span>
|
||||||
<span id="market-info" title="" class="d-none"></span>
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<div id="export-wrap">
|
<div id="export-wrap">
|
||||||
<button id="export-btn" title="Export" aria-label="Export">⤓</button>
|
<button id="export-btn" title="Export">⤓</button>
|
||||||
<div id="export-menu" class="hidden">
|
<div id="export-menu" class="hidden">
|
||||||
<button data-action="image" data-i18n="exportImage">Copy as image</button>
|
|
||||||
<button data-action="copy" data-i18n="exportCopy">Copy sheet</button>
|
<button data-action="copy" data-i18n="exportCopy">Copy sheet</button>
|
||||||
<button data-action="save-sum" data-i18n="exportSum">Save as .sum</button>
|
<button data-action="save-sum" data-i18n="exportSum">Save as .sum</button>
|
||||||
<button data-action="export-txt" data-i18n="exportTxt">Export as text</button>
|
<button data-action="export-txt" data-i18n="exportTxt">Export as text</button>
|
||||||
|
|
@ -47,14 +45,6 @@
|
||||||
</main>
|
</main>
|
||||||
<div id="settings-panel" class="hidden">
|
<div id="settings-panel" class="hidden">
|
||||||
<h2 data-i18n="settings">Settings</h2>
|
<h2 data-i18n="settings">Settings</h2>
|
||||||
<div id="settings-tabs" role="tablist">
|
|
||||||
<button class="tab active" data-tab="general" data-i18n="tabGeneral">General</button>
|
|
||||||
<button class="tab" data-tab="format" data-i18n="tabFormat">Format</button>
|
|
||||||
<button class="tab" data-tab="data" data-i18n="tabData">Data</button>
|
|
||||||
<button class="tab" data-tab="updates" data-i18n="tabUpdates">Updates</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-tabpanel" data-tabpanel="general">
|
|
||||||
<label><span data-i18n="theme">Theme</span>
|
<label><span data-i18n="theme">Theme</span>
|
||||||
<select id="set-theme">
|
<select id="set-theme">
|
||||||
<option value="system" data-i18n="system">System</option>
|
<option value="system" data-i18n="system">System</option>
|
||||||
|
|
@ -62,30 +52,6 @@
|
||||||
<option value="dark" data-i18n="dark">Dark</option>
|
<option value="dark" data-i18n="dark">Dark</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label><span data-i18n="language">Language</span>
|
|
||||||
<select id="set-lang">
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="ru">Русский</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label><span data-i18n="fontsize">Font size</span>
|
|
||||||
<input id="set-fontsize" type="number" min="10" max="32" />
|
|
||||||
</label>
|
|
||||||
<label><span data-i18n="resultswidth">Results column</span>
|
|
||||||
<input id="set-resultswidth" type="range" min="20" max="60" step="1" />
|
|
||||||
</label>
|
|
||||||
<label class="row"><input id="set-alwaysontop" type="checkbox" />
|
|
||||||
<span data-i18n="alwaysontop">Always on top</span>
|
|
||||||
</label>
|
|
||||||
<label><span data-i18n="hotkey">Global hotkey</span>
|
|
||||||
<input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" />
|
|
||||||
</label>
|
|
||||||
<label class="row"><input id="set-autostart" type="checkbox" />
|
|
||||||
<span data-i18n="autostart">Launch at startup</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-tabpanel hidden" data-tabpanel="format">
|
|
||||||
<label><span data-i18n="precision">Decimal places</span>
|
<label><span data-i18n="precision">Decimal places</span>
|
||||||
<input id="set-precision" type="number" min="0" max="15" />
|
<input id="set-precision" type="number" min="0" max="15" />
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -102,40 +68,36 @@
|
||||||
<option value=",">0,5</option>
|
<option value=",">0,5</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label><span data-i18n="dateformat">Date format</span>
|
<label><span data-i18n="language">Language</span>
|
||||||
<select id="set-dateformat">
|
<select id="set-lang">
|
||||||
<option value="system" data-i18n="sysDefault">System</option>
|
<option value="en">English</option>
|
||||||
<option value="dmy">31.12.2024</option>
|
<option value="ru">Русский</option>
|
||||||
<option value="mdy">12/31/2024</option>
|
|
||||||
<option value="iso">2024-12-31</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
<label><span data-i18n="hotkey">Global hotkey</span>
|
||||||
|
<input id="set-hotkey" type="text" placeholder="Ctrl+Alt+N" />
|
||||||
<div class="settings-tabpanel hidden" data-tabpanel="data">
|
</label>
|
||||||
<label><span data-i18n="datadir">Data folder</span>
|
<label class="row"><input id="set-autostart" type="checkbox" />
|
||||||
<button id="set-datadir" class="path-btn" title=""></button>
|
<span data-i18n="autostart">Launch at startup</span>
|
||||||
|
</label>
|
||||||
|
<label class="row"><input id="set-alwaysontop" type="checkbox" />
|
||||||
|
<span data-i18n="alwaysontop">Always on top</span>
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="fontsize">Font size</span>
|
||||||
|
<input id="set-fontsize" type="number" min="10" max="32" />
|
||||||
|
</label>
|
||||||
|
<label><span data-i18n="resultswidth">Results column</span>
|
||||||
|
<input id="set-resultswidth" type="range" min="20" max="60" step="1" />
|
||||||
</label>
|
</label>
|
||||||
<label><span data-i18n="bindays">Bin retention (days)</span>
|
<label><span data-i18n="bindays">Bin retention (days)</span>
|
||||||
<input id="set-bindays" type="number" min="1" max="365" step="1" />
|
<input id="set-bindays" type="number" min="1" max="365" step="1" />
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-tabpanel-buttons">
|
<label><span data-i18n="datadir">Data folder</span>
|
||||||
|
<button id="set-datadir" class="path-btn" title=""></button>
|
||||||
|
</label>
|
||||||
|
<div class="settings-footer">
|
||||||
<button id="open-extensions" data-i18n="extensions">Extensions folder</button>
|
<button id="open-extensions" data-i18n="extensions">Extensions folder</button>
|
||||||
<button id="open-backups" data-i18n="backups">Backups</button>
|
<button id="open-backups" data-i18n="backups">Backups</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-tabpanel hidden" data-tabpanel="updates">
|
|
||||||
<label class="row"><input id="set-autoupdate" type="checkbox" />
|
|
||||||
<span data-i18n="autoUpdate">Automatically check for updates</span>
|
|
||||||
</label>
|
|
||||||
<div class="settings-tabpanel-buttons">
|
|
||||||
<button id="check-update" data-i18n="checkUpdate">Check for updates</button>
|
|
||||||
</div>
|
|
||||||
<div id="app-version" class="settings-version"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-footer">
|
|
||||||
<button id="close-settings" data-i18n="done">Done</button>
|
<button id="close-settings" data-i18n="done">Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -151,7 +113,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="search-overlay" class="hidden">
|
<div id="search-overlay" class="hidden">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input id="search-input" type="text" autocomplete="off" spellcheck="false" aria-label="Search sheets" />
|
<input id="search-input" type="text" autocomplete="off" spellcheck="false" />
|
||||||
<div id="search-results"></div>
|
<div id="search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -1,19 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"version": "1.2.9",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"version": "1.2.9",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.0",
|
"@codemirror/autocomplete": "^6.18.0",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/language": "^6.10.0",
|
"@codemirror/language": "^6.10.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.36.0",
|
"@codemirror/view": "^6.36.0",
|
||||||
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||||
|
|
@ -22,8 +21,7 @@
|
||||||
"@tauri-apps/plugin-opener": "^2.2.0",
|
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"decimal.js": "^10.4.3",
|
"decimal.js": "^10.4.3"
|
||||||
"quickjs-emscripten-core": "^0.32.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.5.0",
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
|
|
@ -534,21 +532,6 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jitl/quickjs-ffi-types": {
|
|
||||||
"version": "0.32.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
|
||||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@jitl/quickjs-singlefile-browser-release-sync": {
|
|
||||||
"version": "0.32.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-browser-release-sync/-/quickjs-singlefile-browser-release-sync-0.32.0.tgz",
|
|
||||||
"integrity": "sha512-Hfdl7rh8dzxNWFRiYAYNbhn0RMF1/tO6SMH2mUW0aTibqwaAtqPRbi4WkwaIDlhNz8Z4dksJi1Zjl1R54Jsc/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
|
@ -1642,9 +1625,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.18",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -1698,9 +1681,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.24",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -1718,7 +1701,7 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.16",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -1726,15 +1709,6 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/quickjs-emscripten-core": {
|
|
||||||
"version": "0.32.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
|
||||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.61.1",
|
"version": "4.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"name": "summarum",
|
"name": "summarum",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.9",
|
"version": "1.0.1",
|
||||||
"description": "Summarum — natural language calculator notebook",
|
"description": "Summarum — natural language calculator notebook for Windows",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"@codemirror/language": "^6.10.0",
|
"@codemirror/language": "^6.10.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.36.0",
|
"@codemirror/view": "^6.36.0",
|
||||||
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||||
|
|
@ -28,8 +27,7 @@
|
||||||
"@tauri-apps/plugin-opener": "^2.2.0",
|
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"decimal.js": "^10.4.3",
|
"decimal.js": "^10.4.3"
|
||||||
"quickjs-emscripten-core": "^0.32.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.5.0",
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ function draw(size: number): Buffer {
|
||||||
|
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const pngs = new Map<number, Buffer>();
|
const pngs = new Map<number, Buffer>();
|
||||||
for (const size of [16, 32, 48, 64, 128, 256, 512, 1024]) {
|
for (const size of [16, 32, 48, 64, 128, 256, 512]) {
|
||||||
pngs.set(size, encodePng(size, draw(size)));
|
pngs.set(size, encodePng(size, draw(size)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,38 +154,3 @@ function buildIco(sizes: number[]): Buffer {
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
|
fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
|
||||||
console.log("icon.ico (6 sizes)");
|
console.log("icon.ico (6 sizes)");
|
||||||
|
|
||||||
// .icns: "icns" magic + total length, then [OSType][length incl. header][PNG].
|
|
||||||
// Both lengths are big-endian and count their own 8-byte header. The tag set
|
|
||||||
// mirrors what `iconutil` emits for a modern .iconset — every ic*/icp* entry
|
|
||||||
// below carries a plain PNG payload (10.7+), so no JPEG 2000 encoder is needed.
|
|
||||||
const ICNS_ENTRIES: Array<[string, number]> = [
|
|
||||||
["icp4", 16],
|
|
||||||
["icp5", 32],
|
|
||||||
["ic11", 32], // 16@2x
|
|
||||||
["ic12", 64], // 32@2x
|
|
||||||
["ic07", 128],
|
|
||||||
["ic13", 256], // 128@2x
|
|
||||||
["ic08", 256],
|
|
||||||
["ic14", 512], // 256@2x
|
|
||||||
["ic09", 512],
|
|
||||||
["ic10", 1024], // 512@2x
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildIcns(): Buffer {
|
|
||||||
const parts = ICNS_ENTRIES.map(([tag, size]) => {
|
|
||||||
const data = pngs.get(size)!;
|
|
||||||
const head = Buffer.alloc(8);
|
|
||||||
head.write(tag, 0, 4, "ascii");
|
|
||||||
head.writeUInt32BE(8 + data.length, 4);
|
|
||||||
return Buffer.concat([head, data]);
|
|
||||||
});
|
|
||||||
const body = Buffer.concat(parts);
|
|
||||||
const header = Buffer.alloc(8);
|
|
||||||
header.write("icns", 0, 4, "ascii");
|
|
||||||
header.writeUInt32BE(8 + body.length, 4);
|
|
||||||
return Buffer.concat([header, body]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(outDir, "icon.icns"), buildIcns());
|
|
||||||
console.log(`icon.icns (${ICNS_ENTRIES.length} entries)`);
|
|
||||||
|
|
|
||||||
38
src-tauri/Cargo.lock
generated
38
src-tauri/Cargo.lock
generated
|
|
@ -1094,21 +1094,6 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-executor",
|
|
||||||
"futures-io",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1116,7 +1101,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1184,7 +1168,6 @@ version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
|
|
@ -3038,7 +3021,6 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
|
@ -3058,14 +3040,12 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.4.2",
|
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
@ -3105,7 +3085,7 @@ dependencies = [
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.5.0",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3727,10 +3707,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "summarum"
|
name = "summarum"
|
||||||
version = "1.2.9"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -4927,19 +4906,6 @@ dependencies = [
|
||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-streams"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[package]
|
[package]
|
||||||
name = "summarum"
|
name = "summarum"
|
||||||
version = "1.2.9"
|
version = "1.0.1"
|
||||||
description = "Summarum — natural language calculator notebook"
|
description = "Summarum — natural language calculator notebook for Windows"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|
@ -18,9 +18,8 @@ tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
futures = "0.3.32"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
// WARNING: extensions run as trusted local code with full access to your files.
|
||||||
|
// Only load scripts you wrote yourself or fully trust.
|
||||||
|
|
||||||
// Summarum extension example.
|
// Summarum extension example.
|
||||||
// Drop .js files into this folder and restart the app.
|
// Drop .js files into this folder and restart the app.
|
||||||
// API: numi.setVariable, numi.addUnit, numi.addFunction.
|
// API: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||||
//
|
|
||||||
// Scripts run in a WebAssembly sandbox: no files, no network, no app internals,
|
|
||||||
// and no console/setTimeout/fetch — only plain JS and the numi API above.
|
|
||||||
|
|
||||||
// A global variable usable on any sheet, e.g. `salary * vat`
|
// A global variable usable on any sheet, e.g. `salary * vat`
|
||||||
// numi.setVariable("vat", { "double": 20 });
|
// numi.setVariable("vat", { "double": 20 });
|
||||||
|
|
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Summarum",
|
"productName": "Summarum",
|
||||||
"version": "1.2.9",
|
"version": "1.0.1",
|
||||||
"identifier": "app.summarum.calc",
|
"identifier": "app.summarum.calc",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
@ -23,19 +23,19 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
|
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src ipc: http://ipc.localhost https://open.er-api.com https://api.coingecko.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"targets": ["nsis"],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.png",
|
"icons/icon.png",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico"
|
||||||
"icons/icon.icns"
|
|
||||||
],
|
],
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
|
|
@ -44,7 +44,13 @@
|
||||||
"description": "Summarum calculation sheet",
|
"description": "Summarum calculation sheet",
|
||||||
"role": "Editor"
|
"role": "Editor"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "currentUser",
|
||||||
|
"languages": ["English", "Russian"]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"bundle": {
|
|
||||||
"targets": ["deb", "rpm", "appimage"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"bundle": {
|
|
||||||
"targets": ["app", "dmg"],
|
|
||||||
"macOS": {
|
|
||||||
"signingIdentity": "-"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"bundle": {
|
|
||||||
"targets": ["nsis"],
|
|
||||||
"windows": {
|
|
||||||
"nsis": {
|
|
||||||
"installMode": "currentUser",
|
|
||||||
"languages": ["English", "Russian"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
detectDateOrder,
|
|
||||||
resolveZone,
|
|
||||||
startOfToday,
|
|
||||||
addToDate,
|
|
||||||
isCalendarUnit,
|
|
||||||
} from "./datetime";
|
|
||||||
|
|
||||||
describe("datetime", () => {
|
|
||||||
describe("detectDateOrder", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should detect dmy (e.g. en-GB)", () => {
|
|
||||||
expect(detectDateOrder("en-GB")).toBe("dmy");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should detect mdy (e.g. en-US)", () => {
|
|
||||||
expect(detectDateOrder("en-US")).toBe("mdy");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should default to dmy if neither is found", () => {
|
|
||||||
const mockFormatToParts = vi.fn().mockReturnValue([
|
|
||||||
{ type: "year", value: "2000" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
|
|
||||||
formatToParts: mockFormatToParts,
|
|
||||||
}) as any);
|
|
||||||
|
|
||||||
expect(detectDateOrder()).toBe("dmy");
|
|
||||||
mockDateTimeFormat.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveZone", () => {
|
|
||||||
it("should resolve 'local' to system timezone", () => {
|
|
||||||
const mockResolvedOptions = vi.fn().mockReturnValue({ timeZone: "America/Los_Angeles" });
|
|
||||||
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
|
|
||||||
resolvedOptions: mockResolvedOptions,
|
|
||||||
}) as any);
|
|
||||||
|
|
||||||
expect(resolveZone(["local"])).toBe("America/Los_Angeles");
|
|
||||||
mockDateTimeFormat.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resolve known abbreviations", () => {
|
|
||||||
expect(resolveZone(["utc"])).toBe("UTC");
|
|
||||||
expect(resolveZone(["est"])).toBe("America/New_York");
|
|
||||||
expect(resolveZone(["pst"])).toBe("America/Los_Angeles");
|
|
||||||
expect(resolveZone(["jst"])).toBe("Asia/Tokyo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resolve known cities", () => {
|
|
||||||
expect(resolveZone(["new", "york"])).toBe("America/New_York");
|
|
||||||
expect(resolveZone(["london"])).toBe("Europe/London");
|
|
||||||
expect(resolveZone(["tokyo"])).toBe("Asia/Tokyo");
|
|
||||||
expect(resolveZone(["buenos", "aires"])).toBe("America/Argentina/Buenos_Aires");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resolve russian cities", () => {
|
|
||||||
expect(resolveZone(["нью", "йорк"])).toBe("America/New_York");
|
|
||||||
expect(resolveZone(["москва"])).toBe("Europe/Moscow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should ignore punctuation and case", () => {
|
|
||||||
expect(resolveZone(["New-York"])).toBe("America/New_York");
|
|
||||||
expect(resolveZone(["LONdoN."])).toBe("Europe/London");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resolve raw IANA timezone", () => {
|
|
||||||
expect(resolveZone(["Europe", "Berlin"])).toBe("Europe/Berlin");
|
|
||||||
expect(resolveZone(["America", "Argentina", "Mendoza"])).toBe("America/Argentina/Mendoza");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null for unknown timezones", () => {
|
|
||||||
expect(resolveZone(["Unknown", "City"])).toBeNull();
|
|
||||||
expect(resolveZone(["Invalid/Timezone"])).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("startOfToday", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return midnight of the current day", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2024-05-15T14:30:00Z"));
|
|
||||||
|
|
||||||
const expected = new Date("2024-05-15T14:30:00Z");
|
|
||||||
expected.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
expect(startOfToday()).toBe(expected.getTime());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("addToDate", () => {
|
|
||||||
it("should add days", () => {
|
|
||||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 5, "day");
|
|
||||||
expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add weeks", () => {
|
|
||||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 2, "week");
|
|
||||||
expect(new Date(res).toISOString()).toBe("2024-01-15T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add months", () => {
|
|
||||||
const ms = new Date("2024-01-15T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 2, "month");
|
|
||||||
expect(new Date(res).toISOString()).toBe("2024-03-15T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clamp end of month correctly", () => {
|
|
||||||
const ms = new Date("2024-01-31T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 1, "month");
|
|
||||||
// 2024 is a leap year, so Feb 29
|
|
||||||
expect(new Date(res).toISOString()).toBe("2024-02-29T12:00:00.000Z");
|
|
||||||
|
|
||||||
const ms2 = new Date("2023-01-31T12:00:00Z").getTime();
|
|
||||||
const res2 = addToDate(ms2, 1, "month");
|
|
||||||
// 2023 is not a leap year, so Feb 28
|
|
||||||
expect(new Date(res2).toISOString()).toBe("2023-02-28T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add years", () => {
|
|
||||||
const ms = new Date("2024-01-15T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 3, "year");
|
|
||||||
expect(new Date(res).toISOString()).toBe("2027-01-15T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clamp end of month when adding years to leap day", () => {
|
|
||||||
const ms = new Date("2024-02-29T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 1, "year");
|
|
||||||
expect(new Date(res).toISOString()).toBe("2025-02-28T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle unit ids with prefixes", () => {
|
|
||||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 5, "unit:day");
|
|
||||||
expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return original ms if amount is not an integer", () => {
|
|
||||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 1.5, "day");
|
|
||||||
expect(res).toBe(ms);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return original ms for unhandled units", () => {
|
|
||||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
|
||||||
const res = addToDate(ms, 1, "hour");
|
|
||||||
expect(res).toBe(ms);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isCalendarUnit", () => {
|
|
||||||
it("should return true for valid calendar units with integer amounts", () => {
|
|
||||||
expect(isCalendarUnit("day", 1)).toBe(true);
|
|
||||||
expect(isCalendarUnit("week", 5)).toBe(true);
|
|
||||||
expect(isCalendarUnit("month", -2)).toBe(true);
|
|
||||||
expect(isCalendarUnit("year", 10)).toBe(true);
|
|
||||||
expect(isCalendarUnit("unit:day", 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for floating point amounts", () => {
|
|
||||||
expect(isCalendarUnit("day", 1.5)).toBe(false);
|
|
||||||
expect(isCalendarUnit("month", 0.5)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for non-calendar units", () => {
|
|
||||||
expect(isCalendarUnit("hour", 1)).toBe(false);
|
|
||||||
expect(isCalendarUnit("minute", 1)).toBe(false);
|
|
||||||
expect(isCalendarUnit("unit:meter", 1)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle edge cases for amount", () => {
|
|
||||||
expect(isCalendarUnit("day", 0)).toBe(true);
|
|
||||||
expect(isCalendarUnit("day", Number.MAX_SAFE_INTEGER)).toBe(true);
|
|
||||||
expect(isCalendarUnit("day", Number.MIN_SAFE_INTEGER)).toBe(true);
|
|
||||||
|
|
||||||
expect(isCalendarUnit("day", NaN)).toBe(false);
|
|
||||||
expect(isCalendarUnit("day", Infinity)).toBe(false);
|
|
||||||
expect(isCalendarUnit("day", -Infinity)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle edge cases for unitId string format", () => {
|
|
||||||
expect(isCalendarUnit("", 1)).toBe(false);
|
|
||||||
expect(isCalendarUnit(":", 1)).toBe(false);
|
|
||||||
|
|
||||||
// string.split(":") behavior:
|
|
||||||
expect(isCalendarUnit("time:month", 1)).toBe(true);
|
|
||||||
expect(isCalendarUnit(":day", 1)).toBe(true);
|
|
||||||
expect(isCalendarUnit("day:", 1)).toBe(false);
|
|
||||||
expect(isCalendarUnit("a:day:c", 1)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for case-sensitive mismatch", () => {
|
|
||||||
expect(isCalendarUnit("Day", 1)).toBe(false);
|
|
||||||
expect(isCalendarUnit("YEAR", 1)).toBe(false);
|
|
||||||
expect(isCalendarUnit("MONTH", 1)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,4 @@
|
||||||
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
// Date helpers and a city → IANA timezone map for "time in Tokyo".
|
||||||
import { DateOrder } from "./types";
|
|
||||||
|
|
||||||
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
|
|
||||||
export function detectDateOrder(locale?: string): DateOrder {
|
|
||||||
const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" })
|
|
||||||
.formatToParts(new Date(2000, 11, 31));
|
|
||||||
for (const p of parts) {
|
|
||||||
if (p.type === "day") return "dmy";
|
|
||||||
if (p.type === "month") return "mdy";
|
|
||||||
}
|
|
||||||
return "dmy";
|
|
||||||
}
|
|
||||||
|
|
||||||
const Z: Record<string, string> = {
|
const Z: Record<string, string> = {
|
||||||
// ---- common abbreviations
|
// ---- common abbreviations
|
||||||
|
|
@ -105,13 +93,8 @@ export function startOfToday(): number {
|
||||||
* survives DST transitions; months/years clamp to the end of the target
|
* survives DST transitions; months/years clamp to the end of the target
|
||||||
* month (Jan 31 + 1 month = Feb 28, not Mar 3).
|
* month (Jan 31 + 1 month = Feb 28, not Mar 3).
|
||||||
*/
|
*/
|
||||||
/** Strips a prefixed unit id ("milli:second") down to its base ("second"). */
|
|
||||||
function baseUnitId(unitId: string): string {
|
|
||||||
return unitId.includes(":") ? unitId.split(":")[1] : unitId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addToDate(ms: number, amount: number, unitId: string): number {
|
export function addToDate(ms: number, amount: number, unitId: string): number {
|
||||||
const baseId = baseUnitId(unitId);
|
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
|
||||||
const d = new Date(ms);
|
const d = new Date(ms);
|
||||||
if ((baseId === "day" || baseId === "week") && Number.isInteger(amount)) {
|
if ((baseId === "day" || baseId === "week") && Number.isInteger(amount)) {
|
||||||
d.setDate(d.getDate() + amount * (baseId === "week" ? 7 : 1));
|
d.setDate(d.getDate() + amount * (baseId === "week" ? 7 : 1));
|
||||||
|
|
@ -139,6 +122,6 @@ function lastDayOfMonth(d: Date): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCalendarUnit(unitId: string, amount: number): boolean {
|
export function isCalendarUnit(unitId: string, amount: number): boolean {
|
||||||
const baseId = baseUnitId(unitId);
|
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
|
||||||
return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount);
|
return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,6 @@ function calc(expr: string): string | null {
|
||||||
return r[r.length - 1].text;
|
return r[r.length - 1].text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateText(doc: string): (string | null)[] {
|
|
||||||
return eng.evaluateDocument(doc).map((x) => x.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
describe("edge cases", () => {
|
||||||
it("russian mixed sentence", () => {
|
it("russian mixed sentence", () => {
|
||||||
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
|
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
|
||||||
|
|
@ -49,11 +45,11 @@ describe("edge cases", () => {
|
||||||
expect(calc("hello world")).toBe(null);
|
expect(calc("hello world")).toBe(null);
|
||||||
});
|
});
|
||||||
it("sum stops at header", () => {
|
it("sum stops at header", () => {
|
||||||
const r = evaluateText("5\n# section\n10\n20\nsum");
|
const r = eng.evaluateDocument("5\n# section\n10\n20\nsum").map((x) => x.text);
|
||||||
expect(r).toEqual(["5", null, "10", "20", "30"]);
|
expect(r).toEqual(["5", null, "10", "20", "30"]);
|
||||||
});
|
});
|
||||||
it("variables with units survive math", () => {
|
it("variables with units survive math", () => {
|
||||||
const r = evaluateText("rent = $500\nfood = $200\nrent + food");
|
const r = eng.evaluateDocument("rent = $500\nfood = $200\nrent + food").map((x) => x.text);
|
||||||
expect(r).toEqual(["$500", "$200", "$700"]);
|
expect(r).toEqual(["$500", "$200", "$700"]);
|
||||||
});
|
});
|
||||||
it("big numbers with grouping", () => {
|
it("big numbers with grouping", () => {
|
||||||
|
|
@ -149,7 +145,7 @@ describe("crypto", () => {
|
||||||
|
|
||||||
describe("regressions", () => {
|
describe("regressions", () => {
|
||||||
it("a broken line does not kill the document (BigInt from NaN)", () => {
|
it("a broken line does not kill the document (BigInt from NaN)", () => {
|
||||||
const r = evaluateText("2+2\nasin(5) & 3\n3+3");
|
const r = eng.evaluateDocument("2+2\nasin(5) & 3\n3+3").map((x) => x.text);
|
||||||
expect(r).toEqual(["4", null, "6"]);
|
expect(r).toEqual(["4", null, "6"]);
|
||||||
});
|
});
|
||||||
it("non-finite results are suppressed", () => {
|
it("non-finite results are suppressed", () => {
|
||||||
|
|
@ -182,8 +178,8 @@ describe("regressions", () => {
|
||||||
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
|
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
|
||||||
});
|
});
|
||||||
it("single-letter unit names can be variables", () => {
|
it("single-letter unit names can be variables", () => {
|
||||||
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]);
|
expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
|
||||||
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]);
|
expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
|
||||||
});
|
});
|
||||||
it("tiny conversion results show significant digits, not 0", () => {
|
it("tiny conversion results show significant digits, not 0", () => {
|
||||||
expect(calc("1 mm in km")).toBe("0.000001 km");
|
expect(calc("1 mm in km")).toBe("0.000001 km");
|
||||||
|
|
@ -203,7 +199,7 @@ describe("regressions", () => {
|
||||||
expect(calc("4 ^ 50%")).toBe("2");
|
expect(calc("4 ^ 50%")).toBe("2");
|
||||||
});
|
});
|
||||||
it("avg skips incompatible lines in the denominator", () => {
|
it("avg skips incompatible lines in the denominator", () => {
|
||||||
expect(evaluateText("10 kg\n20 kg\n5 hours\navg")).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
|
expect(eng.evaluateDocument("10 kg\n20 kg\n5 hours\navg").map((x) => x.text)).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
|
||||||
});
|
});
|
||||||
it("calendar month add clamps to month end", () => {
|
it("calendar month add clamps to month end", () => {
|
||||||
const doc = eng.evaluateDocument("today + 1 month");
|
const doc = eng.evaluateDocument("today + 1 month");
|
||||||
|
|
@ -211,119 +207,3 @@ describe("regressions", () => {
|
||||||
expect(v?.kind).toBe("date");
|
expect(v?.kind).toBe("date");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("review fixes", () => {
|
|
||||||
it("random() with no arguments returns a value in [0,1]", () => {
|
|
||||||
const r = calc("random()");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
const v = Number(r);
|
|
||||||
expect(v).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(v).toBeLessThanOrEqual(1);
|
|
||||||
});
|
|
||||||
it("random() participates in arithmetic", () => {
|
|
||||||
expect(calc("random() + 1")).not.toBeNull();
|
|
||||||
});
|
|
||||||
it("multiplying same-dimension quantities converts the right operand", () => {
|
|
||||||
expect(calc("2 kg * 500 g")).toBe("1 kg");
|
|
||||||
expect(calc("$12 * 3")).toBe("$36");
|
|
||||||
expect(calc("2 m * 3 m")).toBe("6 m²");
|
|
||||||
});
|
|
||||||
it("exp(x) is Euler's number to the power x, not scientific notation", () => {
|
|
||||||
expect(calc("exp(1)")).toBe("2.72");
|
|
||||||
expect(calc("exp(0)")).toBe("1");
|
|
||||||
});
|
|
||||||
it("scientific notation still works via sci/exponent/exponential", () => {
|
|
||||||
expect(calc("123456 in scientific")).toBe("1.23e5");
|
|
||||||
expect(calc("123456 in exponent")).toBe("1.23e5");
|
|
||||||
});
|
|
||||||
it("timezone names with a slash resolve as IANA ids", () => {
|
|
||||||
expect(calc("time in Europe/Berlin")).toMatch(/^\d{2}:\d{2}$/);
|
|
||||||
});
|
|
||||||
it("a URL in a line is not mistaken for a comment", () => {
|
|
||||||
const r = eng.evaluateDocument("see https://example.com");
|
|
||||||
expect(r[0].commentStart).toBeNull();
|
|
||||||
});
|
|
||||||
it("an inline comment after whitespace still works", () => {
|
|
||||||
const r = eng.evaluateDocument("5 + 5 // note");
|
|
||||||
expect(r[0].text).toBe("10");
|
|
||||||
expect(r[0].commentStart).toBe(6);
|
|
||||||
});
|
|
||||||
it("invalid ISO date does not roll over — falls back to arithmetic", () => {
|
|
||||||
expect(calc("2026-13-45")).toBe("1,968");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("date literal formats", () => {
|
|
||||||
const dmyEng = new SumEngine({ dateFormat: "dmy" });
|
|
||||||
const mdyEng = new SumEngine({ dateFormat: "mdy" });
|
|
||||||
const isoEng = new SumEngine({ dateFormat: "iso" });
|
|
||||||
const calcWith = (e: SumEngine, expr: string) => {
|
|
||||||
const r = e.evaluateDocument(expr);
|
|
||||||
return r[r.length - 1].text;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("dot-separated date respects the configured day/month order", () => {
|
|
||||||
expect(calcWith(dmyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
|
|
||||||
});
|
|
||||||
it("slash-separated date respects the configured day/month order", () => {
|
|
||||||
expect(calcWith(mdyEng, "12/31/2024 - 12/30/2024")).toBe("1 day");
|
|
||||||
});
|
|
||||||
it("ambiguous date (both parts <=12) follows the engine's dateFormat", () => {
|
|
||||||
// dmy: 01/02/2024 = 1 Feb; mdy: 01/02/2024 = 2 Jan
|
|
||||||
expect(calcWith(dmyEng, "01/02/2024 - 2024-01-01")).toBe("31 day");
|
|
||||||
expect(calcWith(mdyEng, "01/02/2024 - 2024-01-01")).toBe("1 day");
|
|
||||||
});
|
|
||||||
it("an unambiguous component (>12) wins regardless of dateFormat", () => {
|
|
||||||
expect(calcWith(mdyEng, "31.12.2024 - 30.12.2024")).toBe("1 day");
|
|
||||||
});
|
|
||||||
it("an invalid date (both components out of range) is not parsed as a date", () => {
|
|
||||||
const r = dmyEng.evaluateDocument("32.13.2024");
|
|
||||||
expect(r[0].value?.kind).not.toBe("date");
|
|
||||||
});
|
|
||||||
it("display format overrides the OS locale layout", () => {
|
|
||||||
expect(calcWith(isoEng, "2024-01-15")).toBe("2024-01-15");
|
|
||||||
expect(calcWith(dmyEng, "2024-01-15")).toBe("15.01.2024");
|
|
||||||
expect(calcWith(mdyEng, "2024-01-15")).toBe("01/15/2024");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("regression 2026-08-12", () => {
|
|
||||||
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
|
|
||||||
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
|
|
||||||
const r = evaluateText(`1+1\n${deep}\n2+2`);
|
|
||||||
expect(r[0]).toBe("2");
|
|
||||||
expect(r[1]).toBeNull();
|
|
||||||
expect(r[2]).toBe("4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("'5 min' means 5 minutes, not the min() aggregate", () => {
|
|
||||||
expect(calc("5 min")).toBe("5 min");
|
|
||||||
expect(calc("5 мин")).toBe("5 min");
|
|
||||||
});
|
|
||||||
it("standalone 'min'/'max' aggregate lines still work", () => {
|
|
||||||
const r = evaluateText("10\n20\n5\nmin");
|
|
||||||
expect(r).toEqual(["10", "20", "5", "5"]);
|
|
||||||
const r2 = evaluateText("10\n20\n5\nmax");
|
|
||||||
expect(r2).toEqual(["10", "20", "5", "20"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
function unitIdOf(expr: string): string | null {
|
|
||||||
const v = eng.evaluateDocument(expr)[0].value;
|
|
||||||
return v?.kind === "quantity" ? v.unit?.id ?? null : null;
|
|
||||||
}
|
|
||||||
it("ambiguous currency aliases resolve to one deliberate currency; the other needs its full name", () => {
|
|
||||||
expect(unitIdOf("100 Manat")).toBe("AZN");
|
|
||||||
expect(unitIdOf("100 Turkmenistan Manat")).toBe("TMT");
|
|
||||||
expect(unitIdOf("100 манат")).toBe("AZN");
|
|
||||||
expect(unitIdOf("100 туркменский манат")).toBe("TMT");
|
|
||||||
expect(unitIdOf("100 FC")).toBe("CDF");
|
|
||||||
expect(unitIdOf("100 Comorian Franc")).toBe("KMF");
|
|
||||||
// "Som"/"Kwacha" go to the larger economy (UZS/ZMW); the smaller one needs its full name
|
|
||||||
expect(unitIdOf("100 Som")).toBe("UZS");
|
|
||||||
expect(unitIdOf("100 Kyrgyzstani Som")).toBe("KGS");
|
|
||||||
expect(unitIdOf("100 Kwacha")).toBe("ZMW");
|
|
||||||
expect(unitIdOf("100 Malawian Kwacha")).toBe("MWK");
|
|
||||||
expect(unitIdOf("100 квача")).toBe("ZMW");
|
|
||||||
expect(unitIdOf("100 малавийская квача")).toBe("MWK");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { describe, it, expect, beforeAll } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
import { SumEngine } from "./index";
|
import { SumEngine } from "./index";
|
||||||
import { qty, EvalError, XRefError } from "./types";
|
|
||||||
import type { XRefResolution, EvalCtx } from "./evaluator";
|
|
||||||
import { evaluate } from "./evaluator";
|
|
||||||
import { formatValue } from "./formatter";
|
|
||||||
|
|
||||||
let eng: SumEngine;
|
let eng: SumEngine;
|
||||||
|
|
||||||
|
|
@ -216,12 +212,6 @@ describe("numeral systems", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("variables and document context", () => {
|
describe("variables and document context", () => {
|
||||||
it("sequence error recovery", () => {
|
|
||||||
eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => {
|
|
||||||
throw new EvalError("test error");
|
|
||||||
});
|
|
||||||
expect(calc("throw_err(1) 2 hours")).toBe("2 h");
|
|
||||||
});
|
|
||||||
it("assignment and reuse", () => {
|
it("assignment and reuse", () => {
|
||||||
expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]);
|
expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]);
|
||||||
});
|
});
|
||||||
|
|
@ -241,33 +231,6 @@ describe("variables and document context", () => {
|
||||||
it("prev", () => {
|
it("prev", () => {
|
||||||
expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]);
|
expect(calcDoc("42\nprev + 1")).toEqual(["42", "43"]);
|
||||||
});
|
});
|
||||||
it("count", () => {
|
|
||||||
expect(calcDoc("10\n20\n30\ncount")).toEqual(["10", "20", "30", "3"]);
|
|
||||||
expect(calcDoc("10\n20\n\n30\ncount")).toEqual(["10", "20", null, "30", "1"]);
|
|
||||||
});
|
|
||||||
it("min / max", () => {
|
|
||||||
expect(calcDoc("10\n20\n5\nmin")).toEqual(["10", "20", "5", "5"]);
|
|
||||||
expect(calcDoc("10\n20\n5\nmax")).toEqual(["10", "20", "5", "20"]);
|
|
||||||
});
|
|
||||||
it("min / max with units", () => {
|
|
||||||
expect(calcDoc("10 m\n5 m\n20 m\nmin")).toEqual(["10 m", "5 m", "20 m", "5 m"]);
|
|
||||||
expect(calcDoc("10 m\n5 m\n20 m\nmax")).toEqual(["10 m", "5 m", "20 m", "20 m"]);
|
|
||||||
});
|
|
||||||
it("product", () => {
|
|
||||||
expect(calcDoc("2\n3\n4\nproduct")).toEqual(["2", "3", "4", "24"]);
|
|
||||||
});
|
|
||||||
it("random — deterministic per line text", () => {
|
|
||||||
const [a] = calcDoc("random(1, 6)");
|
|
||||||
const [b] = calcDoc("random(1, 6)");
|
|
||||||
expect(a).toBe(b); // same text → same seed → same result
|
|
||||||
const [c] = calcDoc("random(1, 6) ");
|
|
||||||
expect(c).not.toBe(a); // different text → different seed
|
|
||||||
});
|
|
||||||
it("random — stays in range", () => {
|
|
||||||
const v = Number(calc("random(1, 100)"));
|
|
||||||
expect(v).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(v).toBeLessThanOrEqual(100);
|
|
||||||
});
|
|
||||||
it("headers split blocks and produce no result", () => {
|
it("headers split blocks and produce no result", () => {
|
||||||
expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]);
|
expect(calcDoc("# Food\n10\n20\nsum")).toEqual([null, "10", "20", "30"]);
|
||||||
});
|
});
|
||||||
|
|
@ -292,42 +255,6 @@ describe("dates", () => {
|
||||||
const t = calc("time in Tokyo");
|
const t = calc("time in Tokyo");
|
||||||
expect(t).toMatch(/^\d{2}:\d{2}$/);
|
expect(t).toMatch(/^\d{2}:\d{2}$/);
|
||||||
});
|
});
|
||||||
it("ISO date literal produces a formatted date", () => {
|
|
||||||
const r = calc("2024-01-01");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
expect(typeof r).toBe("string");
|
|
||||||
});
|
|
||||||
it("ISO date subtraction: 2026-01-01 - 2024-01-01 = 731 days", () => {
|
|
||||||
expect(calc("2026-01-01 - 2024-01-01")).toBe("731 day");
|
|
||||||
});
|
|
||||||
it("days until future date returns positive days", () => {
|
|
||||||
const r = calc("days until 2099-01-01");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
it("days since past date returns positive days", () => {
|
|
||||||
const r = calc("days since 2000-01-01");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
expect(Number(r!.replace(/[^\d.-]/g, ""))).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("historical rates", () => {
|
|
||||||
it("in EUR on date uses injected historical rate", () => {
|
|
||||||
eng.setHistoricalRates("2024-01-01", { EUR: 0.9, USD: 1 });
|
|
||||||
expect(calc("1000 USD in EUR on 2024-01-01")).toBe("€ 900");
|
|
||||||
});
|
|
||||||
it("missing historical date returns null", () => {
|
|
||||||
expect(calc("500 USD in EUR on 1800-01-01")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("goal seek", () => {
|
|
||||||
it("? * 1.2 = 1000", () => expect(calc("? * 1.2 = 1000")).toBe("833.33"));
|
|
||||||
it("? + 50 = 200", () => expect(calc("? + 50 = 200")).toBe("150"));
|
|
||||||
it("100 - ? = 30", () => expect(calc("100 - ? = 30")).toBe("70"));
|
|
||||||
it("? / 4 = 5", () => expect(calc("? / 4 = 5")).toBe("20"));
|
|
||||||
it("? - ? = 5 has no solution", () => expect(calc("? - ? = 5")).toBeNull());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extension API", () => {
|
describe("extension API", () => {
|
||||||
|
|
@ -335,13 +262,6 @@ describe("extension API", () => {
|
||||||
eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 });
|
eng.addUnit({ id: "horse", phrases: "horse, horses, hrs", baseUnitId: "meter", format: "hrs", ratio: 2.4 });
|
||||||
expect(calc("2 horses in meters")).toBe("4.8 m");
|
expect(calc("2 horses in meters")).toBe("4.8 m");
|
||||||
});
|
});
|
||||||
it("addUnit throws on id collision instead of silently corrupting unitsById", () => {
|
|
||||||
expect(() =>
|
|
||||||
eng.addUnit({ id: "horse", phrases: "nag", baseUnitId: "meter", format: "nag", ratio: 99 })
|
|
||||||
).toThrow(/horse/);
|
|
||||||
// the original registration must survive the failed collision
|
|
||||||
expect(calc("2 horses in meters")).toBe("4.8 m");
|
|
||||||
});
|
|
||||||
it("addFunction (zum from Sample.js)", () => {
|
it("addFunction (zum from Sample.js)", () => {
|
||||||
eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double }));
|
eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double }));
|
||||||
expect(calc("zum(2;3)")).toBe("5");
|
expect(calc("zum(2;3)")).toBe("5");
|
||||||
|
|
@ -351,104 +271,3 @@ describe("extension API", () => {
|
||||||
expect(calc("myvar * 2")).toBe("$10");
|
expect(calc("myvar * 2")).toBe("$10");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function calcWithResolver(
|
|
||||||
expr: string,
|
|
||||||
resolver: (sheet: string, key: string) => XRefResolution,
|
|
||||||
): string | null {
|
|
||||||
const r = eng.evaluateDocument(expr, resolver);
|
|
||||||
return r[r.length - 1].text;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("grand total", () => {
|
|
||||||
it("totalValueOf's raw value formats identically to totalOf's string", () => {
|
|
||||||
const results = eng.evaluateDocument("$100\n$50");
|
|
||||||
const raw = eng.totalValueOf(results);
|
|
||||||
expect(raw).not.toBeNull();
|
|
||||||
expect(formatValue(raw!, eng.settings)).toBe(eng.totalOf(results));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cross-sheet references", () => {
|
|
||||||
function resolver(sheet: string, key: string): XRefResolution {
|
|
||||||
const s = sheet.toLowerCase();
|
|
||||||
if (s === "budget") {
|
|
||||||
if (key === "total") return { ok: true, value: qty(150) };
|
|
||||||
if (key === "rent") return { ok: true, value: qty(500, eng.reg.unitsById.get("meter")!) };
|
|
||||||
if (key === "last") return { ok: true, value: qty(7) };
|
|
||||||
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
|
|
||||||
}
|
|
||||||
if (s === "trip to lisbon") {
|
|
||||||
if (key === "food") return { ok: true, value: qty(25) };
|
|
||||||
return { ok: false, reason: `no variable "${key}" in "${sheet}"` };
|
|
||||||
}
|
|
||||||
return { ok: false, reason: `sheet "${sheet}" not found` };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("resolves @Sheet.total", () => {
|
|
||||||
expect(calcWithResolver("@Budget.total", resolver)).toBe("150");
|
|
||||||
});
|
|
||||||
it("resolves @Sheet.var and participates in arithmetic", () => {
|
|
||||||
expect(calcWithResolver("@Budget.rent + 1 m", resolver)).toBe("501 m");
|
|
||||||
});
|
|
||||||
it("resolves @Sheet.last", () => {
|
|
||||||
expect(calcWithResolver("@Budget.last", resolver)).toBe("7");
|
|
||||||
});
|
|
||||||
it("unit conversion over a resolved reference", () => {
|
|
||||||
expect(calcWithResolver("@Budget.rent in cm", resolver)).toBe("50,000 cm");
|
|
||||||
});
|
|
||||||
it("bracket form for titles with spaces", () => {
|
|
||||||
expect(calcWithResolver("@[Trip to Lisbon].food", resolver)).toBe("25");
|
|
||||||
});
|
|
||||||
it("unresolved sheet sets a line error instead of crashing", () => {
|
|
||||||
const r = eng.evaluateDocument("@Nope.total", resolver);
|
|
||||||
expect(r[0].value).toBeNull();
|
|
||||||
expect(r[0].error).toBe('sheet "Nope" not found');
|
|
||||||
});
|
|
||||||
it("unresolved key sets a line error", () => {
|
|
||||||
const r = eng.evaluateDocument("@Budget.nope", resolver);
|
|
||||||
expect(r[0].value).toBeNull();
|
|
||||||
expect(r[0].error).toBe('no variable "nope" in "Budget"');
|
|
||||||
});
|
|
||||||
it("ordinary successful line leaves LineResult.error undefined", () => {
|
|
||||||
const r = eng.evaluateDocument("$100", resolver);
|
|
||||||
expect(r[0].error).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("propagates a resolver-reported circular reference", () => {
|
|
||||||
const cyc = (): XRefResolution => ({ ok: false, reason: "circular reference" });
|
|
||||||
const r = eng.evaluateDocument("@A.total", cyc);
|
|
||||||
expect(r[0].value).toBeNull();
|
|
||||||
expect(r[0].error).toBe("circular reference");
|
|
||||||
});
|
|
||||||
it("no resolver given: resolves to null without throwing", () => {
|
|
||||||
expect(() => eng.evaluateDocument("@Budget.total")).not.toThrow();
|
|
||||||
const r = eng.evaluateDocument("@Budget.total");
|
|
||||||
expect(r[0].value).toBeNull();
|
|
||||||
expect(r[0].error).toBe('sheet "Budget" not found');
|
|
||||||
});
|
|
||||||
it("an assignment line reports the assigned name via LineResult.assign", () => {
|
|
||||||
const r = eng.evaluateDocument("rent = $500");
|
|
||||||
expect(r[0].assign).toBe("rent");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("evaluation errors", () => {
|
|
||||||
it("throws EvalError for unknown variable", () => {
|
|
||||||
const ctx = {
|
|
||||||
vars: new Map(),
|
|
||||||
reg: eng.reg,
|
|
||||||
historicalRates: {},
|
|
||||||
} as unknown as EvalCtx;
|
|
||||||
expect(() => evaluate({ k: "var", name: "unknown" }, ctx)).toThrowError(EvalError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws XRefError for unresolved xref", () => {
|
|
||||||
const ctx = {
|
|
||||||
vars: new Map(),
|
|
||||||
reg: eng.reg,
|
|
||||||
historicalRates: {},
|
|
||||||
resolveXRef: () => ({ ok: false, reason: "xref error" }),
|
|
||||||
} as unknown as EvalCtx;
|
|
||||||
expect(() => evaluate({ k: "xref", sheet: "Sheet", key: "key" }, ctx)).toThrowError(XRefError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { toBase, fromBase, convertQ, attachUnit } from "./evaluator";
|
|
||||||
import { qty, pct, EvalError, Decimal } from "./types";
|
|
||||||
import { buildRegistry } from "./registry";
|
|
||||||
|
|
||||||
const reg = buildRegistry();
|
|
||||||
const getUnit = (id: string) => {
|
|
||||||
const u = reg.unitsById.get(id);
|
|
||||||
if (!u) throw new Error(`unit ${id} not found`);
|
|
||||||
return u;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("quantity conversion", () => {
|
|
||||||
it("toBase works", () => {
|
|
||||||
// 1 km = 1000 m
|
|
||||||
const km = getUnit("kilo:meter");
|
|
||||||
const res1 = toBase(qty(2, km));
|
|
||||||
expect(res1.toNumber()).toBe(2000);
|
|
||||||
|
|
||||||
// 0 C = 273.15 K
|
|
||||||
const c = getUnit("celsius");
|
|
||||||
const res2 = toBase(qty(0, c));
|
|
||||||
expect(res2.toNumber()).toBe(273.15);
|
|
||||||
|
|
||||||
// 10 mpg
|
|
||||||
const mpg = getUnit("mpg");
|
|
||||||
const res3 = toBase(qty(10, mpg));
|
|
||||||
expect(res3.toNumber()).toBeCloseTo(23.5215, 4);
|
|
||||||
|
|
||||||
// error on zero for reciprocal
|
|
||||||
expect(() => toBase(qty(0, mpg))).toThrow(EvalError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fromBase works", () => {
|
|
||||||
const km = getUnit("kilo:meter");
|
|
||||||
const res1 = fromBase(new Decimal(2000), km);
|
|
||||||
expect(res1.toNumber()).toBe(2);
|
|
||||||
|
|
||||||
const c = getUnit("celsius");
|
|
||||||
const res2 = fromBase(new Decimal(273.15), c);
|
|
||||||
expect(res2.toNumber()).toBe(0);
|
|
||||||
|
|
||||||
const mpg = getUnit("mpg");
|
|
||||||
const res3 = fromBase(new Decimal(23.5215), mpg);
|
|
||||||
expect(res3.toNumber()).toBeCloseTo(10, 4);
|
|
||||||
|
|
||||||
expect(() => fromBase(new Decimal(0), mpg)).toThrow(EvalError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("convertQ works", () => {
|
|
||||||
const m = getUnit("meter");
|
|
||||||
const km = getUnit("kilo:meter");
|
|
||||||
|
|
||||||
// 2000m -> km
|
|
||||||
const q1 = convertQ(qty(2000, m), km);
|
|
||||||
expect(q1.value.toNumber()).toBe(2);
|
|
||||||
expect(q1.unit?.id).toBe("kilo:meter");
|
|
||||||
|
|
||||||
// c -> f
|
|
||||||
const c = getUnit("celsius");
|
|
||||||
const f = getUnit("fahrenheit");
|
|
||||||
const q2 = convertQ(qty(0, c), f);
|
|
||||||
expect(q2.value.toNumber()).toBeCloseTo(32);
|
|
||||||
|
|
||||||
// fail on mismatched dimensions
|
|
||||||
const kg = getUnit("kilo:gram");
|
|
||||||
expect(() => convertQ(qty(1, m), kg)).toThrow(EvalError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attachUnit works", () => {
|
|
||||||
const m = getUnit("meter");
|
|
||||||
const km = getUnit("kilo:meter");
|
|
||||||
const kg = getUnit("kilo:gram");
|
|
||||||
|
|
||||||
// attaching to dimensionless
|
|
||||||
const r1 = attachUnit(qty(2), m);
|
|
||||||
expect(r1).toMatchObject({ kind: "quantity", unit: { id: "meter" } });
|
|
||||||
|
|
||||||
// attaching matching dimension does conversion
|
|
||||||
const r2 = attachUnit(qty(2000, m), km);
|
|
||||||
expect((r2 as any).value.toNumber()).toBe(2);
|
|
||||||
expect((r2 as any).unit?.id).toBe("kilo:meter");
|
|
||||||
|
|
||||||
// attaching mismatched dimension throws
|
|
||||||
expect(() => attachUnit(qty(1, m), kg)).toThrow(EvalError);
|
|
||||||
|
|
||||||
// attaching to non-quantity throws
|
|
||||||
expect(() => attachUnit(pct(50), m)).toThrow(EvalError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
// Walks the AST with the document context (variables, line results for
|
// Walks the AST with the document context (variables, line results for
|
||||||
// sum/avg/prev) and produces unit-aware values.
|
// sum/avg/prev) and produces unit-aware values.
|
||||||
import { Decimal, DEC_ZERO, DEC_ONE, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types";
|
import { Decimal, EvalError, Quantity, Unit, Value, qty, pct } from "./types";
|
||||||
import { Node, ConvTarget } from "./parser";
|
import { Node, ConvTarget } from "./parser";
|
||||||
import { Registry } from "./registry";
|
import { Registry } from "./registry";
|
||||||
import {
|
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
|
||||||
resolveZone,
|
|
||||||
startOfToday,
|
|
||||||
addToDate,
|
|
||||||
isCalendarUnit,
|
|
||||||
} from "./datetime";
|
|
||||||
|
|
||||||
export interface LineCtx {
|
export interface LineCtx {
|
||||||
/** values of lines above (null = no result) */
|
/** values of lines above (null = no result) */
|
||||||
|
|
@ -16,25 +11,15 @@ export interface LineCtx {
|
||||||
/** per-line flags for block boundaries */
|
/** per-line flags for block boundaries */
|
||||||
lineKinds: ("empty" | "header" | "normal")[];
|
lineKinds: ("empty" | "header" | "normal")[];
|
||||||
index: number;
|
index: number;
|
||||||
/** raw line text — used to seed random() deterministically */
|
|
||||||
lineText: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvalCtx {
|
export interface EvalCtx {
|
||||||
reg: Registry;
|
reg: Registry;
|
||||||
vars: Map<string, Value>;
|
vars: Map<string, Value>;
|
||||||
line: LineCtx;
|
line: LineCtx;
|
||||||
/** date → { code → rate-per-usd } */
|
|
||||||
historicalRates?: Map<string, Map<string, number>>;
|
|
||||||
/** cross-sheet `@Sheet.key` lookup; absent outside the app (tests, extensions) */
|
|
||||||
resolveXRef?: XRefResolver;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type XRefResolution =
|
const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
|
||||||
| { ok: true; value: Value }
|
|
||||||
| { ok: false; reason: string };
|
|
||||||
export type XRefResolver = (sheet: string, key: string) => XRefResolution;
|
|
||||||
|
|
||||||
const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
|
const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
|
||||||
|
|
||||||
export function evaluate(node: Node, ctx: EvalCtx): Value {
|
export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
|
|
@ -42,34 +27,55 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
case "num":
|
case "num":
|
||||||
return qty(node.v, null, node.repr);
|
return qty(node.v, null, node.repr);
|
||||||
case "const":
|
case "const":
|
||||||
return evaluateConst(node);
|
switch (node.name) {
|
||||||
case "var":
|
case "pi": return qty(PI);
|
||||||
case "xref":
|
case "e": return qty(E);
|
||||||
case "unknown":
|
case "half": return qty(new Decimal("0.5"));
|
||||||
return evaluateRef(node, ctx);
|
case "onehalf": return qty(new Decimal("1.5"));
|
||||||
|
}
|
||||||
|
case "var": {
|
||||||
|
const v = ctx.vars.get(node.name);
|
||||||
|
if (!v) throw new EvalError(`unknown variable ${node.name}`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
case "date":
|
case "date":
|
||||||
return evalDateWord(node.word);
|
return evalDateWord(node.word);
|
||||||
case "datelit":
|
|
||||||
return { kind: "date", ms: node.ms, hasTime: false };
|
|
||||||
case "goalseek":
|
|
||||||
return evalGoalSeek(node.lhs, node.rhs, ctx);
|
|
||||||
case "agg":
|
case "agg":
|
||||||
return evalAgg(node.name, ctx);
|
return evalAgg(node.name, ctx);
|
||||||
case "neg":
|
case "neg": {
|
||||||
case "pct":
|
const v = evaluate(node.x, ctx);
|
||||||
case "fact":
|
if (v.kind === "quantity") return { ...v, value: v.value.neg() };
|
||||||
case "scale":
|
if (v.kind === "percent") return pct(v.value.neg());
|
||||||
return evaluateMath(node, ctx);
|
throw new EvalError("cannot negate a date");
|
||||||
case "unit":
|
}
|
||||||
case "curr":
|
case "pct": {
|
||||||
return evaluateUnitMod(node, ctx);
|
const v = evaluate(node.x, ctx);
|
||||||
|
if (v.kind === "quantity" && !v.unit) return pct(v.value);
|
||||||
|
if (v.kind === "percent") return v;
|
||||||
|
throw new EvalError("% needs a plain number");
|
||||||
|
}
|
||||||
|
case "fact": {
|
||||||
|
const v = asScalar(evaluate(node.x, ctx));
|
||||||
|
return qty(factorial(v));
|
||||||
|
}
|
||||||
|
case "unit": {
|
||||||
|
const v = evaluate(node.x, ctx);
|
||||||
|
return attachUnit(v, node.unit);
|
||||||
|
}
|
||||||
|
case "curr": {
|
||||||
|
const unit = ctx.reg.makeCurrencyUnit(node.code);
|
||||||
|
if (!unit) throw new EvalError(`no rate for ${node.code}`);
|
||||||
|
const v = evaluate(node.x, ctx);
|
||||||
|
return attachUnit(v, unit);
|
||||||
|
}
|
||||||
|
case "scale": {
|
||||||
|
const v = evaluate(node.x, ctx);
|
||||||
|
if (v.kind === "quantity") return { ...v, value: v.value.mul(node.mult) };
|
||||||
|
if (v.kind === "percent") return pct(v.value.mul(node.mult));
|
||||||
|
throw new EvalError("cannot scale a date");
|
||||||
|
}
|
||||||
case "bin":
|
case "bin":
|
||||||
return evalBin(
|
return evalBin(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx), ctx);
|
||||||
node.op,
|
|
||||||
evaluate(node.l, ctx),
|
|
||||||
evaluate(node.r, ctx),
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
case "bit":
|
case "bit":
|
||||||
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
|
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
|
||||||
case "pctop":
|
case "pctop":
|
||||||
|
|
@ -77,13 +83,30 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
|
||||||
case "conv":
|
case "conv":
|
||||||
return evalConv(evaluate(node.x, ctx), node.target, ctx);
|
return evalConv(evaluate(node.x, ctx), node.target, ctx);
|
||||||
case "call":
|
case "call":
|
||||||
return evalCall(
|
return evalCall(node.name, node.args.map((a) => evaluate(a, ctx)), ctx);
|
||||||
node.name,
|
case "seq": {
|
||||||
node.args.map((a) => evaluate(a, ctx)),
|
let acc: Value | null = null;
|
||||||
ctx,
|
for (const item of node.items) {
|
||||||
);
|
let v: Value;
|
||||||
case "seq":
|
try {
|
||||||
return evaluateSeq(node, ctx);
|
v = evaluate(item, ctx);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
acc?.kind === "quantity" && v.kind === "quantity" &&
|
||||||
|
acc.unit && v.unit && acc.unit.dimension === v.unit.dimension &&
|
||||||
|
acc.unit.dimension !== "scalar"
|
||||||
|
) {
|
||||||
|
// "2 hours 30 min", "5 ft 4 in" — adjacent same-dimension quantities add up
|
||||||
|
acc = addQ(acc, v);
|
||||||
|
} else {
|
||||||
|
acc = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!acc) throw new EvalError("empty expression");
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +118,7 @@ function asScalar(v: Value): Decimal {
|
||||||
throw new EvalError("expected a number");
|
throw new EvalError("expected a number");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachUnit(v: Value, unit: Unit): Value {
|
function attachUnit(v: Value, unit: Unit): Value {
|
||||||
if (v.kind === "quantity") {
|
if (v.kind === "quantity") {
|
||||||
if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit);
|
if (v.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit);
|
||||||
if (v.unit) throw new EvalError("unit mismatch");
|
if (v.unit) throw new EvalError("unit mismatch");
|
||||||
|
|
@ -128,8 +151,7 @@ export function fromBase(base: Decimal, unit: Unit): Decimal {
|
||||||
|
|
||||||
export function convertQ(q: Quantity, unit: Unit): Quantity {
|
export function convertQ(q: Quantity, unit: Unit): Quantity {
|
||||||
if (!q.unit) return { ...q, unit };
|
if (!q.unit) return { ...q, unit };
|
||||||
if (q.unit.dimension !== unit.dimension)
|
if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
|
||||||
throw new EvalError("dimension mismatch");
|
|
||||||
return qty(fromBase(toBase(q), unit), unit);
|
return qty(fromBase(toBase(q), unit), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,52 +161,41 @@ function addQ(a: Quantity, b: Quantity): Quantity {
|
||||||
|
|
||||||
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
|
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
|
||||||
if (a.unit && b.unit) {
|
if (a.unit && b.unit) {
|
||||||
if (a.unit.dimension !== b.unit.dimension)
|
if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
|
||||||
throw new EvalError("dimension mismatch");
|
|
||||||
if (a.unit.offset || b.unit.offset) {
|
if (a.unit.offset || b.unit.offset) {
|
||||||
// temperatures: operate in the left unit's scale
|
// temperatures: operate in the left unit's scale
|
||||||
const bInA = convertQ(b, a.unit);
|
const bInA = convertQ(b, a.unit);
|
||||||
return qty(
|
return qty(sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value), a.unit);
|
||||||
sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value),
|
|
||||||
a.unit,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const base =
|
const base = sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
|
||||||
sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
|
|
||||||
return qty(fromBase(base, a.unit), a.unit);
|
return qty(fromBase(base, a.unit), a.unit);
|
||||||
}
|
}
|
||||||
const unit = a.unit ?? b.unit ?? null;
|
const unit = a.unit ?? b.unit ?? null;
|
||||||
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
|
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalPercentArith(
|
function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value, ctx: EvalCtx): Value {
|
||||||
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
// ---- dates
|
||||||
l: Value,
|
if (l.kind === "date" || r.kind === "date") return evalDateArith(op, l, r);
|
||||||
r: Value,
|
|
||||||
): Value {
|
// ---- percent combinations
|
||||||
|
if (l.kind === "percent" || r.kind === "percent") {
|
||||||
if (l.kind === "percent" && r.kind === "percent") {
|
if (l.kind === "percent" && r.kind === "percent") {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus":
|
case "plus": return pct(l.value.add(r.value));
|
||||||
return pct(l.value.add(r.value));
|
case "minus": return pct(l.value.sub(r.value));
|
||||||
case "minus":
|
case "mul": return pct(l.value.mul(r.value).div(100));
|
||||||
return pct(l.value.sub(r.value));
|
case "div": return qty(l.value.div(r.value));
|
||||||
case "mul":
|
default: throw new EvalError("bad percent op");
|
||||||
return pct(l.value.mul(r.value).div(100));
|
|
||||||
case "div":
|
|
||||||
return qty(l.value.div(r.value));
|
|
||||||
default:
|
|
||||||
throw new EvalError("bad percent op");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const q = (l.kind === "quantity" ? l : r) as Quantity;
|
const q = (l.kind === "quantity" ? l : r) as Quantity;
|
||||||
const p = (
|
const p = (l.kind === "percent" ? l : r).value;
|
||||||
l.kind === "percent" ? l : (r as { kind: "percent"; value: Decimal })
|
|
||||||
).value;
|
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus": return qty(q.value.mul(DEC_ONE.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(DEC_ONE.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");
|
||||||
|
|
@ -194,22 +205,15 @@ function evalPercentArith(
|
||||||
return l.kind === "percent"
|
return l.kind === "percent"
|
||||||
? qty(p.div(100).pow(q.value))
|
? qty(p.div(100).pow(q.value))
|
||||||
: qty(q.value.pow(p.div(100)), q.unit);
|
: qty(q.value.pow(p.div(100)), q.unit);
|
||||||
default:
|
default: throw new EvalError("bad percent op");
|
||||||
throw new EvalError("bad percent op");
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function evalQuantityArith(
|
const a = l as Quantity;
|
||||||
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
const b = r as Quantity;
|
||||||
a: Quantity,
|
|
||||||
b: Quantity,
|
|
||||||
ctx: EvalCtx,
|
|
||||||
): Value {
|
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "plus":
|
case "plus": return numericAdd(a, b, 1);
|
||||||
return numericAdd(a, b, 1);
|
case "minus": return numericAdd(a, b, -1);
|
||||||
case "minus":
|
|
||||||
return numericAdd(a, b, -1);
|
|
||||||
case "mul": {
|
case "mul": {
|
||||||
if (a.unit && b.unit) {
|
if (a.unit && b.unit) {
|
||||||
const da = a.unit.dimension;
|
const da = a.unit.dimension;
|
||||||
|
|
@ -223,9 +227,6 @@ function evalQuantityArith(
|
||||||
const u = ctx.reg.unitsById.get(targetId);
|
const u = ctx.reg.unitsById.get(targetId);
|
||||||
if (u) return qty(baseProduct.div(u.ratio), u);
|
if (u) return qty(baseProduct.div(u.ratio), u);
|
||||||
}
|
}
|
||||||
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
|
|
||||||
if (da === db)
|
|
||||||
return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
|
|
||||||
return qty(a.value.mul(b.value), a.unit);
|
return qty(a.value.mul(b.value), a.unit);
|
||||||
}
|
}
|
||||||
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
|
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
|
||||||
|
|
@ -244,77 +245,34 @@ function evalQuantityArith(
|
||||||
case "pow": {
|
case "pow": {
|
||||||
const result = a.value.pow(b.value);
|
const result = a.value.pow(b.value);
|
||||||
if (a.unit?.dimension === "length" && !b.unit) {
|
if (a.unit?.dimension === "length" && !b.unit) {
|
||||||
const id = b.value.eq(2)
|
const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null;
|
||||||
? `sq:${a.unit.id}`
|
|
||||||
: b.value.eq(3)
|
|
||||||
? `cb:${a.unit.id}`
|
|
||||||
: null;
|
|
||||||
const u = id ? ctx.reg.unitsById.get(id) : null;
|
const u = id ? ctx.reg.unitsById.get(id) : null;
|
||||||
if (u) return qty(result, u);
|
if (u) return qty(result, u);
|
||||||
}
|
}
|
||||||
return qty(
|
return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null);
|
||||||
result,
|
|
||||||
b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalBin(
|
|
||||||
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
|
|
||||||
l: Value,
|
|
||||||
r: Value,
|
|
||||||
ctx: EvalCtx,
|
|
||||||
): Value {
|
|
||||||
if (l.kind === "date" || r.kind === "date") {
|
|
||||||
return evalDateArith(op, l, r);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (l.kind === "percent" || r.kind === "percent") {
|
|
||||||
return evalPercentArith(op, l, r);
|
|
||||||
}
|
|
||||||
|
|
||||||
return evalQuantityArith(op, l as Quantity, r as Quantity, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evalBit(op: string, l: Value, r: Value): Value {
|
function evalBit(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind !== "quantity" || r.kind !== "quantity")
|
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
|
||||||
throw new EvalError("bitwise needs integers");
|
if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
|
||||||
if (!l.value.isFinite() || !r.value.isFinite())
|
|
||||||
throw new EvalError("bitwise needs finite integers");
|
|
||||||
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
|
||||||
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
|
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
|
||||||
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n))
|
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
|
||||||
throw new EvalError("bad shift");
|
|
||||||
let out: bigint;
|
let out: bigint;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "band":
|
case "band": out = a & b; break;
|
||||||
out = a & b;
|
case "bor": out = a | b; break;
|
||||||
break;
|
case "bxor": out = a ^ b; break;
|
||||||
case "bor":
|
case "shl": out = a << b; break;
|
||||||
out = a | b;
|
case "shr": out = a >> b; break;
|
||||||
break;
|
default: throw new EvalError("bad bit op");
|
||||||
case "bxor":
|
|
||||||
out = a ^ b;
|
|
||||||
break;
|
|
||||||
case "shl":
|
|
||||||
out = a << b;
|
|
||||||
break;
|
|
||||||
case "shr":
|
|
||||||
out = a >> b;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new EvalError("bad bit op");
|
|
||||||
}
|
}
|
||||||
const repr = l.repr !== "decimal" ? l.repr : r.repr;
|
const repr = l.repr !== "decimal" ? l.repr : r.repr;
|
||||||
return qty(new Decimal(out.toString()), null, repr);
|
return qty(new Decimal(out.toString()), null, repr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** l already a percent -> its value; otherwise treat the scalar as a whole-number percent. */
|
|
||||||
function asPercentValue(l: Value): Decimal {
|
|
||||||
return l.kind === "percent" ? l.value : asScalar(l).mul(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evalPctOp(op: string, l: Value, r: Value): Value {
|
function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
const rq = r.kind === "quantity" ? r : null;
|
const rq = r.kind === "quantity" ? r : null;
|
||||||
const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null;
|
const rv = r.kind === "percent" ? r.value.div(100) : rq ? rq.value : null;
|
||||||
|
|
@ -322,11 +280,11 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
const unit = rq?.unit ?? null;
|
const unit = rq?.unit ?? null;
|
||||||
|
|
||||||
if (op === "of" || op === "off" || op === "on") {
|
if (op === "of" || op === "off" || op === "on") {
|
||||||
const p = asPercentValue(l);
|
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
|
||||||
const factor =
|
const factor =
|
||||||
op === "of" ? p.div(100)
|
op === "of" ? p.div(100)
|
||||||
: op === "off" ? DEC_ONE.sub(p.div(100))
|
: op === "off" ? new Decimal(1).sub(p.div(100))
|
||||||
: DEC_ONE.add(p.div(100));
|
: new Decimal(1).add(p.div(100));
|
||||||
// a percent of a percent stays a percent: 50% of 50% = 25%
|
// a percent of a percent stays a percent: 50% of 50% = 25%
|
||||||
if (r.kind === "percent") return pct(r.value.mul(factor));
|
if (r.kind === "percent") return pct(r.value.mul(factor));
|
||||||
return qty(rv.mul(factor), unit);
|
return qty(rv.mul(factor), unit);
|
||||||
|
|
@ -336,21 +294,20 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
|
||||||
if (rv.isZero()) throw new EvalError("division by zero");
|
if (rv.isZero()) throw new EvalError("division by zero");
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "as_pct_of": return pct(lv.div(rv).mul(100));
|
case "as_pct_of": return pct(lv.div(rv).mul(100));
|
||||||
case "as_pct_off": return pct(DEC_ONE.sub(lv.div(rv)).mul(100));
|
case "as_pct_off": return pct(new Decimal(1).sub(lv.div(rv)).mul(100));
|
||||||
case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
|
case "as_pct_on": return pct(lv.div(rv).sub(1).mul(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// "20% of what is 30"
|
// "20% of what is 30"
|
||||||
const p = asPercentValue(l);
|
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "of_what_is":
|
case "of_what_is": return qty(rv.div(p.div(100)), unit);
|
||||||
return qty(rv.div(p.div(100)), unit);
|
|
||||||
case "off_what_is": {
|
case "off_what_is": {
|
||||||
const d = DEC_ONE.sub(p.div(100));
|
const d = new Decimal(1).sub(p.div(100));
|
||||||
if (d.isZero()) throw new EvalError("division by zero");
|
if (d.isZero()) throw new EvalError("division by zero");
|
||||||
return qty(rv.div(d), unit);
|
return qty(rv.div(d), unit);
|
||||||
}
|
}
|
||||||
case "on_what_is": return qty(rv.div(DEC_ONE.add(p.div(100))), unit);
|
case "on_what_is": return qty(rv.div(new Decimal(1).add(p.div(100))), unit);
|
||||||
}
|
}
|
||||||
throw new EvalError(`bad percent op ${op}`);
|
throw new EvalError(`bad percent op ${op}`);
|
||||||
}
|
}
|
||||||
|
|
@ -359,27 +316,14 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
switch (target.type) {
|
switch (target.type) {
|
||||||
case "unit": {
|
case "unit": {
|
||||||
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
|
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
|
||||||
if (v.kind !== "quantity")
|
if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit");
|
||||||
throw new EvalError("cannot convert date to unit");
|
|
||||||
if (!v.unit) return qty(v.value, target.unit);
|
if (!v.unit) return qty(v.value, target.unit);
|
||||||
return convertQ(v, target.unit);
|
return convertQ(v, target.unit);
|
||||||
}
|
}
|
||||||
case "currency": {
|
case "currency": {
|
||||||
let unit;
|
const unit = ctx.reg.makeCurrencyUnit(target.code);
|
||||||
if (target.onDate) {
|
|
||||||
const dateRates = ctx.historicalRates?.get(target.onDate);
|
|
||||||
const rate = dateRates?.get(target.code);
|
|
||||||
if (rate === undefined)
|
|
||||||
throw new EvalError(
|
|
||||||
`no historical rate for ${target.code} on ${target.onDate}`,
|
|
||||||
);
|
|
||||||
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
|
|
||||||
} else {
|
|
||||||
unit = ctx.reg.makeCurrencyUnit(target.code);
|
|
||||||
if (!unit) throw new EvalError(`no rate for ${target.code}`);
|
if (!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);
|
||||||
}
|
}
|
||||||
|
|
@ -398,17 +342,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
return { ...v, timeZone: zone };
|
return { ...v, timeZone: zone };
|
||||||
}
|
}
|
||||||
case "unix": {
|
case "unix": {
|
||||||
if (v.kind === "date")
|
if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
|
||||||
return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
|
|
||||||
throw new EvalError("unix needs a date");
|
throw new EvalError("unix needs a date");
|
||||||
}
|
}
|
||||||
case "todate": {
|
case "todate": {
|
||||||
if (v.kind !== "quantity" || v.unit)
|
if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number");
|
||||||
throw new EvalError("date needs a plain number");
|
|
||||||
// heuristics: > 1e12 means milliseconds, else seconds
|
// heuristics: > 1e12 means milliseconds, else seconds
|
||||||
const ms = v.value.gt(1e12)
|
const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber();
|
||||||
? v.value.toNumber()
|
|
||||||
: v.value.mul(1000).toNumber();
|
|
||||||
return { kind: "date", ms, hasTime: true };
|
return { kind: "date", ms, hasTime: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -416,25 +356,12 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
|
||||||
|
|
||||||
function evalDateWord(word: string): Value {
|
function evalDateWord(word: string): Value {
|
||||||
switch (word) {
|
switch (word) {
|
||||||
case "today":
|
case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
|
||||||
return { kind: "date", ms: startOfToday(), hasTime: false };
|
case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false };
|
||||||
case "tomorrow": {
|
case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, hasTime: false };
|
||||||
const d = new Date(startOfToday());
|
case "now": return { kind: "date", ms: Date.now(), hasTime: true };
|
||||||
d.setDate(d.getDate() + 1);
|
case "time": case "local": return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
|
||||||
return { kind: "date", ms: d.getTime(), hasTime: false };
|
default: throw new EvalError(`bad date word ${word}`);
|
||||||
}
|
|
||||||
case "yesterday": {
|
|
||||||
const d = new Date(startOfToday());
|
|
||||||
d.setDate(d.getDate() - 1);
|
|
||||||
return { kind: "date", ms: d.getTime(), hasTime: false };
|
|
||||||
}
|
|
||||||
case "now":
|
|
||||||
return { kind: "date", ms: Date.now(), hasTime: true };
|
|
||||||
case "time":
|
|
||||||
case "local":
|
|
||||||
return { kind: "date", ms: Date.now(), hasTime: true, timeOnly: true };
|
|
||||||
default:
|
|
||||||
throw new EvalError(`bad date word ${word}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,16 +369,10 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
if (l.kind === "date" && r.kind === "date") {
|
if (l.kind === "date" && r.kind === "date") {
|
||||||
if (op !== "minus") throw new EvalError("dates only subtract");
|
if (op !== "minus") throw new EvalError("dates only subtract");
|
||||||
// midnight-to-midnight differences count calendar days (robust across DST)
|
// midnight-to-midnight differences count calendar days (robust across DST)
|
||||||
const days =
|
const days = !l.hasTime && !r.hasTime
|
||||||
!l.hasTime && !r.hasTime
|
|
||||||
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
|
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
|
||||||
: new Decimal(l.ms - r.ms).div(86400_000);
|
: new Decimal(l.ms - r.ms).div(86400_000);
|
||||||
return qty(days, {
|
return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
|
||||||
id: "day",
|
|
||||||
dimension: "time",
|
|
||||||
ratio: new Decimal(86400),
|
|
||||||
format: "day",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
|
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
|
||||||
const span = l.kind === "date" ? r : l;
|
const span = l.kind === "date" ? r : l;
|
||||||
|
|
@ -459,8 +380,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
throw new EvalError("date arithmetic needs a time span");
|
throw new EvalError("date arithmetic needs a time span");
|
||||||
}
|
}
|
||||||
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
|
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
|
||||||
if (op === "minus" && l.kind !== "date")
|
if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
|
||||||
throw new EvalError("cannot subtract date from number");
|
|
||||||
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
|
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
|
||||||
let ms: number;
|
let ms: number;
|
||||||
let hasTime = date.hasTime;
|
let hasTime = date.hasTime;
|
||||||
|
|
@ -475,150 +395,15 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
return { ...date, ms, hasTime };
|
return { ...date, ms, hasTime };
|
||||||
}
|
}
|
||||||
|
|
||||||
function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
|
function evalAgg(name: "sum" | "avg" | "prev", ctx: EvalCtx): Value {
|
||||||
const evalF = (x: Decimal): Decimal => {
|
const { lineValues, lineKinds, index } = ctx.line;
|
||||||
const vars = new Map(ctx.vars);
|
if (name === "prev") {
|
||||||
vars.set("__x__", qty(x));
|
|
||||||
const innerCtx: EvalCtx = { ...ctx, vars };
|
|
||||||
const lv = evaluate(lhsNode, innerCtx);
|
|
||||||
const rv = evaluate(rhsNode, innerCtx);
|
|
||||||
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : DEC_ZERO;
|
|
||||||
return toD(lv).minus(toD(rv));
|
|
||||||
};
|
|
||||||
|
|
||||||
const safeEval = (x: Decimal): Decimal | null => {
|
|
||||||
try {
|
|
||||||
return evalF(x);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const f0 = safeEval(DEC_ZERO);
|
|
||||||
const f1 = safeEval(DEC_ONE);
|
|
||||||
|
|
||||||
if (f0 !== null && f1 !== null) {
|
|
||||||
const slope = f1.minus(f0);
|
|
||||||
if (!slope.isZero()) {
|
|
||||||
const root = f0.neg().div(slope);
|
|
||||||
const fRoot = safeEval(root);
|
|
||||||
if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9")))
|
|
||||||
return qty(root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bisection fallback for nonlinear cases
|
|
||||||
let lo = new Decimal(-1e9);
|
|
||||||
let hi = new Decimal(1e9);
|
|
||||||
let flo = safeEval(lo);
|
|
||||||
let fhi = safeEval(hi);
|
|
||||||
|
|
||||||
if (flo === null || fhi === null || flo.mul(fhi).gt(0)) {
|
|
||||||
const probes = [
|
|
||||||
1, 2, 5, 10, 100, 1000, 10000, -1, -2, -5, -10, -100, -1000, -10000,
|
|
||||||
];
|
|
||||||
let found = false;
|
|
||||||
for (const p of probes) {
|
|
||||||
const pDec = new Decimal(p);
|
|
||||||
const fp = safeEval(pDec);
|
|
||||||
if (fp === null) continue;
|
|
||||||
if (flo === null) {
|
|
||||||
lo = pDec;
|
|
||||||
flo = fp;
|
|
||||||
} else if (fhi === null) {
|
|
||||||
hi = pDec;
|
|
||||||
fhi = fp;
|
|
||||||
} else if (flo.mul(fp).lte(0)) {
|
|
||||||
hi = pDec;
|
|
||||||
fhi = fp;
|
|
||||||
} else if (fhi.mul(fp).lte(0)) {
|
|
||||||
lo = pDec;
|
|
||||||
flo = fp;
|
|
||||||
} else continue;
|
|
||||||
if (flo !== null && fhi !== null && flo.mul(fhi).lte(0)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) throw new EvalError("no solution");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const mid = lo.plus(hi).div(2);
|
|
||||||
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
|
|
||||||
const fm = safeEval(mid);
|
|
||||||
if (fm === null) {
|
|
||||||
lo = mid.plus("1e-9");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (flo!.mul(fm).lte(0)) {
|
|
||||||
hi = mid;
|
|
||||||
fhi = fm;
|
|
||||||
} else {
|
|
||||||
lo = mid;
|
|
||||||
flo = fm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return qty(lo.plus(hi).div(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lines whose unit shares the first line's dimension (mixed-dimension blocks skip the rest). */
|
|
||||||
function filterSameDimension(block: Quantity[]): Quantity[] {
|
|
||||||
const refDim = block[0].unit?.dimension ?? null;
|
|
||||||
return block.filter((q) => (q.unit?.dimension ?? null) === refDim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */
|
|
||||||
function reduceCompatible(block: Quantity[]): {
|
|
||||||
acc: Quantity;
|
|
||||||
counted: number;
|
|
||||||
} {
|
|
||||||
let acc = block[0];
|
|
||||||
let counted = 1;
|
|
||||||
for (let i = 1; i < block.length; i++) {
|
|
||||||
try {
|
|
||||||
acc = numericAdd(acc, block[i], 1);
|
|
||||||
counted++;
|
|
||||||
} catch {
|
|
||||||
// skip lines with incompatible dimensions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { acc, counted };
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
|
|
||||||
count: (block) => qty(new Decimal(block.length)),
|
|
||||||
chart: (block) => {
|
|
||||||
const compatible = filterSameDimension(block);
|
|
||||||
if (compatible.length < 2)
|
|
||||||
throw new EvalError("need at least 2 values for chart");
|
|
||||||
return {
|
|
||||||
kind: "chart",
|
|
||||||
points: compatible.map((q) => toBase(q)),
|
|
||||||
unitLabel: block[0].unit?.format ?? null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
min: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).lt(toBase(best)) ? q : best),
|
|
||||||
max: (block) => filterSameDimension(block).reduce((best, q) => toBase(q).gt(toBase(best)) ? q : best),
|
|
||||||
product: (block) => qty(block.reduce((acc, q) => acc.mul(q.value), DEC_ONE)),
|
|
||||||
sum: (block) => reduceCompatible(block).acc,
|
|
||||||
avg: (block) => {
|
|
||||||
const { acc, counted } = reduceCompatible(block);
|
|
||||||
return qty(acc.value.div(counted), acc.unit);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getPrevResult(ctx: EvalCtx): Value {
|
|
||||||
const { lineValues, index } = ctx.line;
|
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAggBlock(ctx: EvalCtx): Quantity[] {
|
|
||||||
const { lineValues, lineKinds, index } = ctx.line;
|
|
||||||
const block: Quantity[] = [];
|
const block: Quantity[] = [];
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
for (let i = index - 1; i >= 0; i--) {
|
||||||
if (lineKinds[i] === "empty" || lineKinds[i] === "header") {
|
if (lineKinds[i] === "empty" || lineKinds[i] === "header") {
|
||||||
|
|
@ -631,250 +416,72 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
|
||||||
}
|
}
|
||||||
if (block.length === 0) throw new EvalError("nothing to aggregate");
|
if (block.length === 0) throw new EvalError("nothing to aggregate");
|
||||||
block.reverse();
|
block.reverse();
|
||||||
return block;
|
let acc = block[0];
|
||||||
}
|
let counted = 1;
|
||||||
|
for (let i = 1; i < block.length; i++) {
|
||||||
function evalAgg(
|
const item = block[i];
|
||||||
name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart",
|
try {
|
||||||
ctx: EvalCtx,
|
acc = numericAdd(acc, item, 1);
|
||||||
): Value {
|
counted++;
|
||||||
if (name === "prev") return getPrevResult(ctx);
|
} catch {
|
||||||
const block = getAggBlock(ctx);
|
// skip lines with incompatible dimensions
|
||||||
const func = AGG_FUNCS[name];
|
|
||||||
if (!func) throw new EvalError(`unknown aggregate function ${name}`);
|
|
||||||
return func(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
const angleArg = (x: Value): Decimal => {
|
|
||||||
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
|
|
||||||
return x.value.mul(x.unit.ratio);
|
|
||||||
}
|
}
|
||||||
return asScalar(x);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUILTIN_FUNCS: Record<string, (x: Value, ctx: EvalCtx) => Value> = {
|
|
||||||
sqrt: (x, ctx) => {
|
|
||||||
if (
|
|
||||||
x.kind === "quantity" &&
|
|
||||||
x.unit?.dimension === "area" &&
|
|
||||||
x.unit.id.startsWith("sq:")
|
|
||||||
) {
|
|
||||||
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
|
|
||||||
if (base) return qty(x.value.sqrt(), base);
|
|
||||||
}
|
}
|
||||||
return qty(asScalar(x).sqrt());
|
if (name === "avg") return qty(acc.value.div(counted), acc.unit);
|
||||||
},
|
return acc;
|
||||||
cbrt: (x) => qty(asScalar(x).cbrt()),
|
}
|
||||||
sin: (x) => qty(Decimal.sin(angleArg(x))),
|
|
||||||
cos: (x) => qty(Decimal.cos(angleArg(x))),
|
|
||||||
tan: (x) => qty(Decimal.tan(angleArg(x))),
|
|
||||||
cot: (x) => qty(DEC_ONE.div(Decimal.tan(angleArg(x)))),
|
|
||||||
asin: (x) => qty(Decimal.asin(asScalar(x))),
|
|
||||||
arcsin: (x) => qty(Decimal.asin(asScalar(x))),
|
|
||||||
acos: (x) => qty(Decimal.acos(asScalar(x))),
|
|
||||||
arccos: (x) => qty(Decimal.acos(asScalar(x))),
|
|
||||||
atan: (x) => qty(Decimal.atan(asScalar(x))),
|
|
||||||
arctan: (x) => qty(Decimal.atan(asScalar(x))),
|
|
||||||
sinh: (x) => qty(Decimal.sinh(asScalar(x))),
|
|
||||||
cosh: (x) => qty(Decimal.cosh(asScalar(x))),
|
|
||||||
tanh: (x) => qty(Decimal.tanh(asScalar(x))),
|
|
||||||
ln: (x) => qty(Decimal.ln(asScalar(x))),
|
|
||||||
lg: (x) => qty(Decimal.log10(asScalar(x))),
|
|
||||||
log: (x) => qty(Decimal.log10(asScalar(x))),
|
|
||||||
log2: (x) => qty(Decimal.log2(asScalar(x))),
|
|
||||||
exp: (x) => qty(Decimal.exp(asScalar(x))),
|
|
||||||
abs: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.abs() }
|
|
||||||
: qty(asScalar(x).abs()),
|
|
||||||
round: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.round() }
|
|
||||||
: qty(asScalar(x).round()),
|
|
||||||
ceil: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.ceil() }
|
|
||||||
: qty(asScalar(x).ceil()),
|
|
||||||
floor: (x) =>
|
|
||||||
x.kind === "quantity"
|
|
||||||
? { ...x, value: x.value.floor() }
|
|
||||||
: qty(asScalar(x).floor()),
|
|
||||||
fact: (x) => qty(factorial(asScalar(x))),
|
|
||||||
factorial: (x) => qty(factorial(asScalar(x))),
|
|
||||||
until: (x) => {
|
|
||||||
if (x.kind !== "date") throw new EvalError("until needs a date");
|
|
||||||
return evalDateArith("minus", x, {
|
|
||||||
kind: "date",
|
|
||||||
ms: startOfToday(),
|
|
||||||
hasTime: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
since: (x) => {
|
|
||||||
if (x.kind !== "date") throw new EvalError("since needs a date");
|
|
||||||
return evalDateArith(
|
|
||||||
"minus",
|
|
||||||
{ kind: "date", ms: startOfToday(), hasTime: false },
|
|
||||||
x,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
|
||||||
const custom = ctx.reg.customFuncs.get(name);
|
const custom = ctx.reg.customFuncs.get(name);
|
||||||
if (custom) return custom(args);
|
if (custom) return custom(args);
|
||||||
if (name === "random") return evalRandom(args, ctx); // random() is valid with no args
|
|
||||||
|
|
||||||
const x = args[0];
|
const x = args[0];
|
||||||
if (!x) throw new EvalError(`${name} needs an argument`);
|
if (!x) throw new EvalError(`${name} needs an argument`);
|
||||||
|
|
||||||
const func = BUILTIN_FUNCS[name];
|
// trig works on angles: degrees convert to radians
|
||||||
if (!func) throw new EvalError(`unknown function ${name}`);
|
const angleArg = (): Decimal => {
|
||||||
return func(x, ctx);
|
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
|
||||||
}
|
return x.value.mul(x.unit.ratio);
|
||||||
|
|
||||||
// Native float, not Decimal: this is a PRNG (mulberry32/strHash bit-twiddling
|
|
||||||
// over 32-bit ints), not arithmetic on user quantities — decimal.js has no
|
|
||||||
// bitwise ops and would add nothing but overhead here.
|
|
||||||
function evalRandom(args: Value[], ctx: EvalCtx): Value {
|
|
||||||
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
|
|
||||||
const rand = mulberry32(seed);
|
|
||||||
if (args.length === 0) return qty(new Decimal(rand));
|
|
||||||
const toNum = (v: Value) => (v.kind === "quantity" ? v.value.toNumber() : 0);
|
|
||||||
const [lo, hi] =
|
|
||||||
args.length === 1
|
|
||||||
? [0, toNum(args[0]!)]
|
|
||||||
: [toNum(args[0]!), toNum(args[1]!)];
|
|
||||||
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
|
|
||||||
const raw = isInt
|
|
||||||
? Math.floor(rand * (hi - lo + 1)) + lo
|
|
||||||
: rand * (hi - lo) + lo;
|
|
||||||
return qty(new Decimal(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
function strHash(s: string): number {
|
|
||||||
let h = 0x811c9dc5;
|
|
||||||
for (let i = 0; i < s.length; i++) {
|
|
||||||
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
|
|
||||||
}
|
}
|
||||||
return h >>> 0;
|
return asScalar(x);
|
||||||
}
|
};
|
||||||
|
const n = () => asScalar(x);
|
||||||
|
|
||||||
function mulberry32(seed: number): number {
|
switch (name) {
|
||||||
seed = (seed + 0x6d2b79f5) | 0;
|
case "sqrt": {
|
||||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
if (base) return qty(x.value.sqrt(), base);
|
||||||
|
}
|
||||||
|
return qty(n().sqrt());
|
||||||
|
}
|
||||||
|
case "cbrt": return qty(n().cbrt());
|
||||||
|
case "sin": return qty(Decimal.sin(angleArg()));
|
||||||
|
case "cos": return qty(Decimal.cos(angleArg()));
|
||||||
|
case "tan": return qty(Decimal.tan(angleArg()));
|
||||||
|
case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
|
||||||
|
case "asin": case "arcsin": return qty(Decimal.asin(n()));
|
||||||
|
case "acos": case "arccos": return qty(Decimal.acos(n()));
|
||||||
|
case "atan": case "arctan": return qty(Decimal.atan(n()));
|
||||||
|
case "sinh": return qty(Decimal.sinh(n()));
|
||||||
|
case "cosh": return qty(Decimal.cosh(n()));
|
||||||
|
case "tanh": return qty(Decimal.tanh(n()));
|
||||||
|
case "ln": return qty(Decimal.ln(n()));
|
||||||
|
case "lg": case "log": return qty(Decimal.log10(n()));
|
||||||
|
case "log2": return qty(Decimal.log2(n()));
|
||||||
|
case "exp": return qty(Decimal.exp(n()));
|
||||||
|
case "abs": return x.kind === "quantity" ? { ...x, value: x.value.abs() } : qty(n().abs());
|
||||||
|
case "round": return x.kind === "quantity" ? { ...x, value: x.value.round() } : qty(n().round());
|
||||||
|
case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
|
||||||
|
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
|
||||||
|
case "fact": case "factorial": return qty(factorial(n()));
|
||||||
|
default: throw new EvalError(`unknown function ${name}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorial(n: Decimal): Decimal {
|
function factorial(n: Decimal): Decimal {
|
||||||
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
|
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
|
||||||
let acc = DEC_ONE;
|
let acc = new Decimal(1);
|
||||||
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
|
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateConst(node: {
|
|
||||||
k: "const";
|
|
||||||
name: "pi" | "e" | "half" | "onehalf";
|
|
||||||
}): Value {
|
|
||||||
switch (node.name) {
|
|
||||||
case "pi":
|
|
||||||
return qty(PI);
|
|
||||||
case "e":
|
|
||||||
return qty(E);
|
|
||||||
case "half":
|
|
||||||
return qty(new Decimal("0.5"));
|
|
||||||
case "onehalf":
|
|
||||||
return qty(new Decimal("1.5"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateRef(node: Node, ctx: EvalCtx): Value {
|
|
||||||
if (node.k === "var") {
|
|
||||||
const v = ctx.vars.get(node.name);
|
|
||||||
if (!v) throw new EvalError(`unknown variable ${node.name}`);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
if (node.k === "xref") {
|
|
||||||
const res = ctx.resolveXRef?.(node.sheet, node.key);
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
throw new XRefError(
|
|
||||||
res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return res.value;
|
|
||||||
}
|
|
||||||
if (node.k === "unknown") {
|
|
||||||
const xv = ctx.vars.get("__x__");
|
|
||||||
if (!xv) throw new EvalError("? used outside goal seek");
|
|
||||||
return xv;
|
|
||||||
}
|
|
||||||
throw new EvalError("invalid ref node");
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateMath(node: Node, ctx: EvalCtx): Value {
|
|
||||||
if (node.k === "neg") {
|
|
||||||
const v = evaluate(node.x, ctx);
|
|
||||||
if (v.kind === "quantity") return { ...v, value: v.value.neg() };
|
|
||||||
if (v.kind === "percent") return pct(v.value.neg());
|
|
||||||
throw new EvalError("cannot negate a date");
|
|
||||||
}
|
|
||||||
if (node.k === "pct") {
|
|
||||||
const v = evaluate(node.x, ctx);
|
|
||||||
if (v.kind === "quantity" && !v.unit) return pct(v.value);
|
|
||||||
if (v.kind === "percent") return v;
|
|
||||||
throw new EvalError("% needs a plain number");
|
|
||||||
}
|
|
||||||
if (node.k === "fact") {
|
|
||||||
const v = asScalar(evaluate(node.x, ctx));
|
|
||||||
return qty(factorial(v));
|
|
||||||
}
|
|
||||||
if (node.k === "scale") {
|
|
||||||
const v = evaluate(node.x, ctx);
|
|
||||||
if (v.kind === "quantity") return { ...v, value: v.value.mul(node.mult) };
|
|
||||||
if (v.kind === "percent") return pct(v.value.mul(node.mult));
|
|
||||||
throw new EvalError("cannot scale a date");
|
|
||||||
}
|
|
||||||
throw new EvalError("invalid math node");
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateUnitMod(node: Node, ctx: EvalCtx): Value {
|
|
||||||
if (node.k === "unit") {
|
|
||||||
const v = evaluate(node.x, ctx);
|
|
||||||
return attachUnit(v, node.unit);
|
|
||||||
}
|
|
||||||
if (node.k === "curr") {
|
|
||||||
const unit = ctx.reg.makeCurrencyUnit(node.code);
|
|
||||||
if (!unit) throw new EvalError(`no rate for ${node.code}`);
|
|
||||||
const v = evaluate(node.x, ctx);
|
|
||||||
return attachUnit(v, unit);
|
|
||||||
}
|
|
||||||
throw new EvalError("invalid unit mod node");
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateSeq(node: { k: "seq"; items: Node[] }, ctx: EvalCtx): Value {
|
|
||||||
let acc: Value | null = null;
|
|
||||||
for (const item of node.items) {
|
|
||||||
let v: Value;
|
|
||||||
try {
|
|
||||||
v = evaluate(item, ctx);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
acc?.kind === "quantity" &&
|
|
||||||
v.kind === "quantity" &&
|
|
||||||
acc.unit &&
|
|
||||||
v.unit &&
|
|
||||||
acc.unit.dimension === v.unit.dimension &&
|
|
||||||
acc.unit.dimension !== "scalar"
|
|
||||||
) {
|
|
||||||
// "2 hours 30 min", "5 ft 4 in" — adjacent same-dimension quantities add up
|
|
||||||
acc = addQ(acc, v);
|
|
||||||
} else {
|
|
||||||
acc = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!acc) throw new EvalError("empty expression");
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { formatNumber, formatValue } from "./formatter";
|
|
||||||
import { Decimal, defaultSettings, qty, pct, DateVal, ChartValue, EngineSettings } from "./types";
|
|
||||||
|
|
||||||
describe("formatNumber", () => {
|
|
||||||
it("formats standard numbers", () => {
|
|
||||||
expect(formatNumber(new Decimal("12.345"), defaultSettings)).toBe("12.35");
|
|
||||||
expect(formatNumber(new Decimal("100"), defaultSettings)).toBe("100");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles custom group separators", () => {
|
|
||||||
const s: EngineSettings = { ...defaultSettings, groupSeparator: " " };
|
|
||||||
expect(formatNumber(new Decimal("1234567.89"), s)).toBe("1 234 567.89");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles custom decimal separators", () => {
|
|
||||||
const s: EngineSettings = { ...defaultSettings, decimalSeparator: "," };
|
|
||||||
expect(formatNumber(new Decimal("12.345"), s)).toBe("12,35");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles scientific notation strings automatically", () => {
|
|
||||||
expect(formatNumber(new Decimal("1e25"), defaultSettings)).toBe("1e+25");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies significant digits fallback for numbers close to zero", () => {
|
|
||||||
// 0.001 rounded to 0 decimals is 0, but since >= 1e-12, it shows significant digits
|
|
||||||
const s: EngineSettings = { ...defaultSettings, precision: 0 };
|
|
||||||
expect(formatNumber(new Decimal("0.001"), s)).toBe("0.001");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats numbers < 1e-12 as conversion noise", () => {
|
|
||||||
const s: EngineSettings = { ...defaultSettings, precision: 2 };
|
|
||||||
expect(formatNumber(new Decimal("1e-13"), s)).toBe("0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatValue", () => {
|
|
||||||
it("formats chart as empty string", () => {
|
|
||||||
const v: ChartValue = { kind: "chart", points: [], unitLabel: null };
|
|
||||||
expect(formatValue(v, defaultSettings)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats percent values", () => {
|
|
||||||
expect(formatValue(pct(25.5), defaultSettings)).toBe("25.5%");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats dates depending on flags", () => {
|
|
||||||
// 2024-01-01 12:00:00 UTC = 1704110400000
|
|
||||||
const d: DateVal = { kind: "date", ms: 1704110400000, hasTime: false, timeZone: "UTC" };
|
|
||||||
expect(formatValue(d, { ...defaultSettings, dateFormat: "iso" })).toBe("2024-01-01");
|
|
||||||
expect(formatValue(d, { ...defaultSettings, dateFormat: "dmy" })).toBe("01.01.2024");
|
|
||||||
expect(formatValue(d, { ...defaultSettings, dateFormat: "mdy" })).toBe("01/01/2024");
|
|
||||||
|
|
||||||
// Test timeOnly
|
|
||||||
const dTime: DateVal = { kind: "date", ms: 1704110400000, hasTime: true, timeZone: "UTC", timeOnly: true };
|
|
||||||
expect(formatValue(dTime, defaultSettings)).toBe("12:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats base conversions", () => {
|
|
||||||
expect(formatValue(qty(255, null, "hex"), defaultSettings)).toBe("0xFF");
|
|
||||||
expect(formatValue(qty(-255, null, "hex"), defaultSettings)).toBe("-0xFF");
|
|
||||||
expect(formatValue(qty(5, null, "binary"), defaultSettings)).toBe("0b101");
|
|
||||||
expect(formatValue(qty(8, null, "octal"), defaultSettings)).toBe("0o10");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats representations", () => {
|
|
||||||
expect(formatValue(qty(123456, null, "scientific"), defaultSettings)).toBe("1.23e5");
|
|
||||||
expect(formatValue(qty(1234.56, null, "plain"), defaultSettings)).toBe("1234.56");
|
|
||||||
expect(formatValue(qty(0.75, null, "fraction"), defaultSettings)).toBe("3/4");
|
|
||||||
expect(formatValue(qty(14, null, "roman"), defaultSettings)).toBe("XIV");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats Roman numerals fallbacks", () => {
|
|
||||||
// Beyond 3999 falls back to formatNumber
|
|
||||||
expect(formatValue(qty(4000, null, "roman"), defaultSettings)).toBe("4,000");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats units", () => {
|
|
||||||
const cm = { id: "cm", dimension: "length" as const, ratio: new Decimal(0.01), format: "cm" };
|
|
||||||
expect(formatValue(qty(10, cm), defaultSettings)).toBe("10 cm");
|
|
||||||
|
|
||||||
const degC = { id: "degC", dimension: "temperature" as const, ratio: new Decimal(1), format: "°C" };
|
|
||||||
expect(formatValue(qty(25, degC), defaultSettings)).toBe("25°C");
|
|
||||||
|
|
||||||
const usd = { id: "USD", dimension: "currency" as const, ratio: new Decimal(1), format: "${}" };
|
|
||||||
expect(formatValue(qty(100, usd), defaultSettings)).toBe("$100");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +1,6 @@
|
||||||
// Turns values into display strings: unit/currency formats plus
|
// Turns values into display strings: unit/currency formats plus
|
||||||
// precision and grouping settings.
|
// precision and grouping settings.
|
||||||
import { Decimal, DateVal, EngineSettings, Value } from "./types";
|
import { Decimal, EngineSettings, Value } from "./types";
|
||||||
|
|
||||||
// Intl.DateTimeFormat construction is relatively expensive and every line with
|
|
||||||
// a date rebuilds one on each render — cache by locale+options instead.
|
|
||||||
const dtfCache = new Map<string, Intl.DateTimeFormat>();
|
|
||||||
function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
||||||
const key = (locale ?? "") + JSON.stringify(opts);
|
|
||||||
let f = dtfCache.get(key);
|
|
||||||
if (!f) {
|
|
||||||
f = new Intl.DateTimeFormat(locale, opts);
|
|
||||||
dtfCache.set(key, f);
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */
|
|
||||||
function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
|
|
||||||
const parts = dtf("en-GB", {
|
|
||||||
timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
||||||
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
||||||
}).formatToParts(v.ms);
|
|
||||||
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? "";
|
|
||||||
const [y, mo, d] = [get("year"), get("month"), get("day")];
|
|
||||||
const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`;
|
|
||||||
return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function group(intPart: string, sep: string): string {
|
function group(intPart: string, sep: string): string {
|
||||||
if (!sep) return intPart;
|
if (!sep) return intPart;
|
||||||
|
|
@ -51,8 +26,6 @@ export function formatNumber(v: Decimal, s: EngineSettings): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(v: Value, s: EngineSettings): string {
|
export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
if (v.kind === "chart") return "";
|
|
||||||
|
|
||||||
if (v.kind === "percent") {
|
if (v.kind === "percent") {
|
||||||
return formatNumber(v.value, s) + "%";
|
return formatNumber(v.value, s) + "%";
|
||||||
}
|
}
|
||||||
|
|
@ -60,23 +33,19 @@ export function formatValue(v: Value, s: EngineSettings): string {
|
||||||
if (v.kind === "date") {
|
if (v.kind === "date") {
|
||||||
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
|
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
|
||||||
if (v.timeOnly) {
|
if (v.timeOnly) {
|
||||||
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
return new Intl.DateTimeFormat(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
||||||
}
|
}
|
||||||
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
|
|
||||||
if (v.hasTime) {
|
if (v.hasTime) {
|
||||||
return dtf(undefined, {
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
||||||
day: "numeric", month: "short", year: "numeric",
|
day: "numeric", month: "short", year: "numeric",
|
||||||
}).format(v.ms);
|
}).format(v.ms);
|
||||||
}
|
}
|
||||||
return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
|
return new Intl.DateTimeFormat(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
// quantity
|
// quantity
|
||||||
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
|
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
|
||||||
if (v.value.abs().gte(new Decimal("1e10000"))) {
|
|
||||||
return formatNumber(v.value, s);
|
|
||||||
}
|
|
||||||
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
|
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
|
||||||
const neg = int.isNegative();
|
const neg = int.isNegative();
|
||||||
const abs = int.abs();
|
const abs = int.abs();
|
||||||
|
|
@ -112,9 +81,6 @@ function withUnit(num: string, f: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best rational approximation via continued fractions: 0.75 -> "3/4". */
|
/** Best rational approximation via continued fractions: 0.75 -> "3/4". */
|
||||||
// Native float, not Decimal: the continued-fraction algorithm is tuned to
|
|
||||||
// float epsilon by design, and this is display formatting, not arithmetic
|
|
||||||
// carried forward into the result.
|
|
||||||
function formatFraction(v: Decimal): string {
|
function formatFraction(v: Decimal): string {
|
||||||
const neg = v.isNegative();
|
const neg = v.isNegative();
|
||||||
const x = v.abs().toNumber();
|
const x = v.abs().toNumber();
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,17 @@
|
||||||
import { logger } from "../logger";
|
|
||||||
// Public facade: evaluates whole documents line by line and exposes the
|
// Public facade: evaluates whole documents line by line and exposes the
|
||||||
// extension API (numi.addUnit / addFunction / setVariable).
|
// extension API (numi.addUnit / addFunction / setVariable).
|
||||||
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
|
import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, defaultSettings, qty } from "./types";
|
||||||
import { Registry, buildRegistry, Completion } from "./registry";
|
import { Registry, buildRegistry, Completion } from "./registry";
|
||||||
import { tokenize, Token } from "./tokenizer";
|
import { tokenize, Token } from "./tokenizer";
|
||||||
import { parseLine, ParsedLine } from "./parser";
|
import { parseLine } from "./parser";
|
||||||
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
|
import { evaluate, EvalCtx, toBase, fromBase } from "./evaluator";
|
||||||
import { formatValue } from "./formatter";
|
import { formatValue } from "./formatter";
|
||||||
import { detectDateOrder } from "./datetime";
|
|
||||||
import snapshot from "./rates-snapshot.json";
|
import snapshot from "./rates-snapshot.json";
|
||||||
import { CRYPTO } from "./extraunits";
|
import { CRYPTO } from "./extraunits";
|
||||||
|
|
||||||
const cryptoSnapshot: Record<string, number> = {};
|
const cryptoSnapshot: Record<string, number> = {};
|
||||||
for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd;
|
for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd;
|
||||||
|
|
||||||
// "//" only starts a comment at line start or after whitespace, so URLs like
|
|
||||||
// "see https://example.com" aren't cut off.
|
|
||||||
const COMMENT_RE = /(^|\s)\/\//;
|
|
||||||
function findCommentStart(line: string): number {
|
|
||||||
const m = COMMENT_RE.exec(line);
|
|
||||||
return m ? m.index + m[1].length : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDateOrder(fmt: EngineSettings["dateFormat"]): DateOrder {
|
|
||||||
return fmt === "dmy" || fmt === "mdy" ? fmt : detectDateOrder();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LineResult {
|
export interface LineResult {
|
||||||
/** formatted result, null when the line has none (text, comment, header, error) */
|
/** formatted result, null when the line has none (text, comment, header, error) */
|
||||||
text: string | null;
|
text: string | null;
|
||||||
|
|
@ -35,10 +21,6 @@ export interface LineResult {
|
||||||
tokens: Token[];
|
tokens: Token[];
|
||||||
/** offset of "//" comment start within the line, if any */
|
/** offset of "//" comment start within the line, if any */
|
||||||
commentStart: number | null;
|
commentStart: number | null;
|
||||||
/** name this line assigns, if any (`rent = $500` → "rent") */
|
|
||||||
assign?: string;
|
|
||||||
/** set when the line references an unresolved cross-sheet value */
|
|
||||||
error?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionUnitSpec {
|
export interface ExtensionUnitSpec {
|
||||||
|
|
@ -57,42 +39,29 @@ export interface ExtensionValue {
|
||||||
export class SumEngine {
|
export class SumEngine {
|
||||||
reg: Registry;
|
reg: Registry;
|
||||||
settings: EngineSettings;
|
settings: EngineSettings;
|
||||||
|
/** variables defined by extensions, persist across evaluations */
|
||||||
private globals = new Map<string, Value>();
|
private globals = new Map<string, Value>();
|
||||||
private historicalRates = new Map<string, Map<string, number>>();
|
|
||||||
/** resolved day/month order for ambiguous date literals like "01/02/2024" */
|
|
||||||
private dateOrder: DateOrder;
|
|
||||||
|
|
||||||
constructor(settings: Partial<EngineSettings> = {}) {
|
constructor(settings: Partial<EngineSettings> = {}) {
|
||||||
this.settings = { ...defaultSettings, ...settings };
|
this.settings = { ...defaultSettings, ...settings };
|
||||||
this.dateOrder = resolveDateOrder(this.settings.dateFormat);
|
|
||||||
this.reg = buildRegistry();
|
this.reg = buildRegistry();
|
||||||
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
|
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
|
||||||
}
|
}
|
||||||
|
|
||||||
setRates(rates: Record<string, number | string>): void {
|
setRates(rates: Record<string, number | string>): void {
|
||||||
|
// snapshot fills codes the live API doesn't return (e.g. crypto when CoinGecko fails)
|
||||||
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates });
|
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates });
|
||||||
}
|
}
|
||||||
|
|
||||||
setHistoricalRates(date: string, rates: Record<string, number>): void {
|
|
||||||
const m = new Map<string, number>();
|
|
||||||
for (const [k, v] of Object.entries(rates)) m.set(k.toUpperCase(), v);
|
|
||||||
this.historicalRates.set(date, m);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasHistoricalRates(date: string): boolean {
|
|
||||||
return this.historicalRates.has(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings(patch: Partial<EngineSettings>): void {
|
updateSettings(patch: Partial<EngineSettings>): void {
|
||||||
this.settings = { ...this.settings, ...patch };
|
this.settings = { ...this.settings, ...patch };
|
||||||
if (patch.dateFormat) this.dateOrder = resolveDateOrder(this.settings.dateFormat);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
completions(): Completion[] {
|
completions(): Completion[] {
|
||||||
return this.reg.completions;
|
return this.reg.completions;
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluateDocument(text: string, resolveXRef?: XRefResolver): LineResult[] {
|
evaluateDocument(text: string): LineResult[] {
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
const results: LineResult[] = [];
|
const results: LineResult[] = [];
|
||||||
const vars = new Map<string, Value>(this.globals);
|
const vars = new Map<string, Value>(this.globals);
|
||||||
|
|
@ -101,7 +70,7 @@ export class SumEngine {
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const rawLine = lines[i];
|
const rawLine = lines[i];
|
||||||
const commentStart = findCommentStart(rawLine);
|
const commentStart = rawLine.indexOf("//");
|
||||||
const line = commentStart >= 0 ? rawLine.slice(0, commentStart) : rawLine;
|
const line = commentStart >= 0 ? rawLine.slice(0, commentStart) : rawLine;
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
|
@ -119,53 +88,37 @@ export class SumEngine {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// tokenize/parseLine/evaluate all run under one try/catch: pathological
|
const tokens = tokenize(line, this.reg);
|
||||||
// input (e.g. stack-overflowing nested parens in the parser) must null
|
|
||||||
// out this one line, not throw past evaluateDocument and kill the rest.
|
|
||||||
let tokens: Token[] = [];
|
|
||||||
let parsed: ParsedLine = { expr: null };
|
|
||||||
let value: Value | null = null;
|
|
||||||
let lineError: string | undefined;
|
|
||||||
try {
|
|
||||||
tokens = tokenize(line, this.reg, this.dateOrder);
|
|
||||||
const knownVars = new Set(vars.keys());
|
const knownVars = new Set(vars.keys());
|
||||||
parsed = parseLine(tokens, knownVars, line);
|
const parsed = parseLine(tokens, knownVars, line);
|
||||||
|
|
||||||
|
let value: Value | null = null;
|
||||||
if (parsed.expr) {
|
if (parsed.expr) {
|
||||||
const ctx: EvalCtx = {
|
const ctx: EvalCtx = {
|
||||||
reg: this.reg,
|
reg: this.reg,
|
||||||
vars,
|
vars,
|
||||||
line: { lineValues, lineKinds, index: i, lineText: rawLine },
|
line: { lineValues, lineKinds, index: i },
|
||||||
historicalRates: this.historicalRates,
|
|
||||||
resolveXRef,
|
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
value = evaluate(parsed.expr, ctx);
|
value = evaluate(parsed.expr, ctx);
|
||||||
}
|
// a line without a result beats a dead sheet — swallow everything,
|
||||||
|
// including extension bugs and BigInt conversion errors
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof XRefError) {
|
if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
|
||||||
lineError = e.message;
|
|
||||||
} else if (e instanceof EvalError) {
|
|
||||||
// Expected evaluation failure; remain silent
|
|
||||||
} else {
|
|
||||||
// Capture unexpected execution anomalies like BigInt or Range errors
|
|
||||||
lineError = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.warn("evaluate failed:", e);
|
|
||||||
}
|
|
||||||
// a line without a result beats a dead sheet.
|
|
||||||
value = null;
|
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);
|
||||||
lineKinds.push("normal");
|
lineKinds.push("normal");
|
||||||
results.push({
|
results.push({
|
||||||
text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null,
|
text: value ? formatValue(value, this.settings) : null,
|
||||||
value,
|
value,
|
||||||
kind: "normal",
|
kind: "normal",
|
||||||
tokens,
|
tokens,
|
||||||
commentStart: commentStart >= 0 ? commentStart : null,
|
commentStart: commentStart >= 0 ? commentStart : null,
|
||||||
assign: parsed.assign,
|
|
||||||
error: lineError,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -178,11 +131,11 @@ export class SumEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grand total of the sheet: sums every line result compatible with the
|
* Grand total of the sheet for the status bar: sums every line result
|
||||||
* first united result's dimension (converted to its unit). Returns null
|
* compatible with the first united result's dimension (converted to its
|
||||||
* when fewer than two lines contribute.
|
* unit). Returns null when fewer than two lines contribute.
|
||||||
*/
|
*/
|
||||||
totalValueOf(results: LineResult[]): Quantity | null {
|
totalOf(results: LineResult[]): string | null {
|
||||||
const qs = results
|
const qs = results
|
||||||
.map((r) => r.value)
|
.map((r) => r.value)
|
||||||
.filter((v): v is Quantity => v !== null && v.kind === "quantity");
|
.filter((v): v is Quantity => v !== null && v.kind === "quantity");
|
||||||
|
|
@ -201,13 +154,7 @@ export class SumEngine {
|
||||||
}
|
}
|
||||||
if (count < 2) return null;
|
if (count < 2) return null;
|
||||||
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
|
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
|
||||||
return qty(value, anchor.unit ?? null);
|
return formatValue(qty(value, anchor.unit ?? null), this.settings);
|
||||||
}
|
|
||||||
|
|
||||||
/** Formatted string for the status bar; see totalValueOf for the raw value. */
|
|
||||||
totalOf(results: LineResult[]): string | null {
|
|
||||||
const v = this.totalValueOf(results);
|
|
||||||
return v ? formatValue(v, this.settings) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- extension API (numi.*)
|
// ---------- extension API (numi.*)
|
||||||
|
|
@ -219,7 +166,6 @@ export class SumEngine {
|
||||||
addUnit(spec: ExtensionUnitSpec): void {
|
addUnit(spec: ExtensionUnitSpec): void {
|
||||||
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
|
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
|
||||||
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
|
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
|
||||||
if (this.reg.unitsById.has(spec.id)) throw new Error(`numi.addUnit: id "${spec.id}" is already registered`);
|
|
||||||
const unit: Unit = {
|
const unit: Unit = {
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
dimension: base.dimension,
|
dimension: base.dimension,
|
||||||
|
|
@ -265,12 +211,10 @@ export class SumEngine {
|
||||||
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
|
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
|
||||||
}
|
}
|
||||||
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
|
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
|
||||||
if (v.kind === "date") return { double: v.ms };
|
return { double: v.ms };
|
||||||
return { double: 0 }; // chart — not representable as ExtensionValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { formatValue } from "./formatter";
|
export { formatValue } from "./formatter";
|
||||||
export type { Value, EngineSettings } from "./types";
|
export type { Value, EngineSettings } from "./types";
|
||||||
export type { Token } from "./tokenizer";
|
export type { Token } from "./tokenizer";
|
||||||
export type { XRefResolution, XRefResolver } from "./evaluator";
|
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { lexLine } from "./lexer";
|
|
||||||
import { Decimal } from "./types";
|
|
||||||
|
|
||||||
describe("lexer", () => {
|
|
||||||
describe("lexLine", () => {
|
|
||||||
it("should lex words and symbols, ignoring spaces", () => {
|
|
||||||
const result = lexLine("hello + world");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "word", raw: "hello", start: 0, end: 5 },
|
|
||||||
{ type: "sym", raw: "+", start: 6, end: 7 },
|
|
||||||
{ type: "word", raw: "world", start: 8, end: 13 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex ISO dates", () => {
|
|
||||||
const result = lexLine("2024-12-31");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "date", raw: "2024-12-31", start: 0, end: 10, dateMs: new Date(2024, 11, 31).getTime() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex dmy dates", () => {
|
|
||||||
const result = lexLine("31.12.2024");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "date", raw: "31.12.2024", start: 0, end: 10, dateMs: new Date(2024, 11, 31).getTime() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex ambiguous dates as dmy by default", () => {
|
|
||||||
const result = lexLine("01/02/2024");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "date", raw: "01/02/2024", start: 0, end: 10, dateMs: new Date(2024, 1, 1).getTime() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex ambiguous dates as mdy if specified", () => {
|
|
||||||
const result = lexLine("01/02/2024", "mdy");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "date", raw: "01/02/2024", start: 0, end: 10, dateMs: new Date(2024, 0, 2).getTime() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fallback when invalid dates are encountered", () => {
|
|
||||||
// 2026-13-45 is invalid as a date, should lex as numbers and syms
|
|
||||||
const result = lexLine("2026-13-45");
|
|
||||||
expect(result[0].type).toBe("num");
|
|
||||||
expect(result[0].raw).toBe("2026");
|
|
||||||
expect(result[1].type).toBe("sym");
|
|
||||||
expect(result[1].raw).toBe("-");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex plain integers", () => {
|
|
||||||
const result = lexLine("42");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "num", raw: "42", start: 0, end: 2, value: new Decimal("42"), repr: "decimal" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex decimal comma numbers", () => {
|
|
||||||
const result = lexLine("42,5");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "num", raw: "42,5", start: 0, end: 4, value: new Decimal("42.5"), repr: "decimal" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex space-grouped numbers", () => {
|
|
||||||
const result = lexLine("1 000 000,5");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "num", raw: "1 000 000,5", start: 0, end: 11, value: new Decimal("1000000.5"), repr: "decimal" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex comma-grouped numbers", () => {
|
|
||||||
const result = lexLine("1,000,000.5");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "num", raw: "1,000,000.5", start: 0, end: 11, value: new Decimal("1000000.5"), repr: "decimal" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex numbers with leading dots", () => {
|
|
||||||
const result = lexLine(".5");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "num", raw: ".5", start: 0, end: 2, value: new Decimal(".5"), repr: "decimal" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex base numbers (hex, bin, oct)", () => {
|
|
||||||
const hex = lexLine("0xff");
|
|
||||||
expect(hex[0]).toMatchObject({ type: "num", raw: "0xff", value: new Decimal(255), repr: "hex" });
|
|
||||||
|
|
||||||
const bin = lexLine("0b1010");
|
|
||||||
expect(bin[0]).toMatchObject({ type: "num", raw: "0b1010", value: new Decimal(10), repr: "binary" });
|
|
||||||
|
|
||||||
const oct = lexLine("0o77");
|
|
||||||
expect(oct[0]).toMatchObject({ type: "num", raw: "0o77", value: new Decimal(63), repr: "octal" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex bare cross-references", () => {
|
|
||||||
const result = lexLine("@sheet.key");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "xref", raw: "@sheet.key", start: 0, end: 10, sheet: "sheet", key: "key" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should lex bracketed cross-references", () => {
|
|
||||||
const result = lexLine("@[my sheet].key");
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ type: "xref", raw: "@[my sheet].key", start: 0, end: 15, sheet: "my sheet", key: "key" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
|
// Splits a line into raw lexemes (numbers, words, symbols). Shared by the
|
||||||
// tokenizer and the phrase registry so both segment text the same way.
|
// tokenizer and the phrase registry so both segment text the same way.
|
||||||
import { DateOrder, Decimal, NumeralRepr } from "./types";
|
import { Decimal, NumeralRepr } from "./types";
|
||||||
|
|
||||||
export interface Lex {
|
export interface Lex {
|
||||||
type: "num" | "word" | "sym" | "date" | "xref";
|
type: "num" | "word" | "sym";
|
||||||
raw: string;
|
raw: string;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
value?: Decimal;
|
value?: Decimal;
|
||||||
repr?: NumeralRepr;
|
repr?: NumeralRepr;
|
||||||
dateMs?: number;
|
|
||||||
sheet?: string;
|
|
||||||
key?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORD_RE = /^[\p{L}_]+/u;
|
const WORD_RE = /^[\p{L}_]+/u;
|
||||||
|
|
@ -23,57 +20,39 @@ const NUM_SPACE_GROUPED_RE = /^\d{1,3}(?:[ ]\d{3})+(?:[.,]\d+)?(?!\d)/;
|
||||||
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
|
const NUM_COMMA_GROUPED_RE = /^\d{1,3}(?:,\d{3})+(?:\.\d+)?(?!\d)/;
|
||||||
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
|
const NUM_DECIMAL_COMMA_RE = /^\d+,\d+/;
|
||||||
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
|
const NUM_PLAIN_RE = /^\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
|
||||||
// 31.12.2024 / 12/31/2024 — same separator on both sides, 4-digit year
|
|
||||||
const SLASH_DOT_DATE_RE = /^(\d{1,2})([./])(\d{1,2})\2(\d{4})(?!\d)/;
|
|
||||||
|
|
||||||
/** ms for a real calendar date; null when the components roll over (2026-13-45) */
|
export function lexLine(text: string): Lex[] {
|
||||||
function validDateMs(y: number, mo: number, d: number): number | null {
|
const out: Lex[] = [];
|
||||||
const dt = new Date(y, mo - 1, d);
|
let i = 0;
|
||||||
return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d
|
while (i < text.length) {
|
||||||
? dt.getTime()
|
const ch = text[i];
|
||||||
: null;
|
if (ch === " " || ch === "\t" || ch === " ") {
|
||||||
}
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rest = text.slice(i);
|
||||||
|
|
||||||
function tryLexDate(rest: string, i: number, dateOrder: DateOrder): Lex | null {
|
if (ch >= "0" && ch <= "9") {
|
||||||
const iso = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(rest);
|
|
||||||
if (iso) {
|
|
||||||
const ms = validDateMs(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
|
||||||
if (ms !== null) {
|
|
||||||
return { type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dm = SLASH_DOT_DATE_RE.exec(rest);
|
|
||||||
if (dm) {
|
|
||||||
const a = Number(dm[1]);
|
|
||||||
const b = Number(dm[3]);
|
|
||||||
// a>12 must be the day, b>12 must be the month; both ≤12 → configured order
|
|
||||||
const dayFirst = a > 12 ? true : b > 12 ? false : dateOrder === "dmy";
|
|
||||||
const y = Number(dm[4]);
|
|
||||||
const ms = dayFirst ? validDateMs(y, b, a) : validDateMs(y, a, b);
|
|
||||||
if (ms !== null) {
|
|
||||||
return { type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryLexBaseNumber(rest: string, i: number): Lex | null {
|
|
||||||
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
|
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
|
||||||
if (hex) {
|
if (hex) {
|
||||||
return { type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" };
|
out.push({ type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" });
|
||||||
|
i += hex[0].length;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const bin = /^0[bB][01]+/.exec(rest);
|
const bin = /^0[bB][01]+/.exec(rest);
|
||||||
if (bin) {
|
if (bin) {
|
||||||
return { type: "num", raw: bin[0], start: i, end: i + bin[0].length, value: new Decimal(parseInt(bin[0].slice(2), 2)), repr: "binary" };
|
out.push({ type: "num", raw: bin[0], start: i, end: i + bin[0].length, value: new Decimal(parseInt(bin[0].slice(2), 2)), repr: "binary" });
|
||||||
|
i += bin[0].length;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const oct = /^0[oO][0-7]+/.exec(rest);
|
const oct = /^0[oO][0-7]+/.exec(rest);
|
||||||
if (oct) {
|
if (oct) {
|
||||||
return { type: "num", raw: oct[0], start: i, end: i + oct[0].length, value: new Decimal(parseInt(oct[0].slice(2), 8)), repr: "octal" };
|
out.push({ type: "num", raw: oct[0], start: i, end: i + oct[0].length, value: new Decimal(parseInt(oct[0].slice(2), 8)), repr: "octal" });
|
||||||
|
i += oct[0].length;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
}
|
if (ch >= "0" && ch <= "9") {
|
||||||
|
|
||||||
function tryLexDecimalNumber(rest: string, i: number): Lex | null {
|
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
let cleaned = "";
|
let cleaned = "";
|
||||||
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
|
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
|
||||||
|
|
@ -94,65 +73,22 @@ function tryLexDecimalNumber(rest: string, i: number): Lex | null {
|
||||||
cleaned = raw;
|
cleaned = raw;
|
||||||
}
|
}
|
||||||
if (raw) {
|
if (raw) {
|
||||||
return { type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), repr: "decimal" };
|
out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), repr: "decimal" });
|
||||||
}
|
i += raw.length;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryLexDotNumber(rest: string, i: number): Lex | null {
|
|
||||||
if (/^\.\d/.test(rest)) {
|
|
||||||
const m = /^\.\d+/.exec(rest)!;
|
|
||||||
return { type: "num", raw: m[0], start: i, end: i + m[0].length, value: new Decimal(m[0]), repr: "decimal" };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryLexXRef(rest: string, i: number): Lex | null {
|
|
||||||
const bracket = /^@\[([^\]]+)\]\.([\p{L}_][\p{L}\d_]*)/u.exec(rest);
|
|
||||||
if (bracket) {
|
|
||||||
return { type: "xref", raw: bracket[0], start: i, end: i + bracket[0].length, sheet: bracket[1].trim(), key: bracket[2] };
|
|
||||||
}
|
|
||||||
const bare = /^@([\p{L}_][\p{L}\d_]*)\.([\p{L}_][\p{L}\d_]*)/u.exec(rest);
|
|
||||||
if (bare) {
|
|
||||||
return { type: "xref", raw: bare[0], start: i, end: i + bare[0].length, sheet: bare[1], key: bare[2] };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryLexWord(rest: string, i: number): Lex | null {
|
|
||||||
const word = WORD_RE.exec(rest);
|
|
||||||
if (word) {
|
|
||||||
return { type: "word", raw: word[0], start: i, end: i + word[0].length };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
|
|
||||||
const out: Lex[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < text.length) {
|
|
||||||
const ch = text[i];
|
|
||||||
if (ch === " " || ch === "\t" || ch === " ") {
|
|
||||||
i++;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const rest = text.slice(i);
|
|
||||||
|
|
||||||
let lex: Lex | null = null;
|
|
||||||
if (ch >= "0" && ch <= "9") {
|
|
||||||
lex = tryLexDate(rest, i, dateOrder) || tryLexBaseNumber(rest, i) || tryLexDecimalNumber(rest, i);
|
|
||||||
} else if (ch === ".") {
|
|
||||||
lex = tryLexDotNumber(rest, i);
|
|
||||||
} else if (ch === "@") {
|
|
||||||
lex = tryLexXRef(rest, i);
|
|
||||||
}
|
}
|
||||||
if (!lex) {
|
if (ch === "." && /^\.\d/.test(rest)) {
|
||||||
lex = tryLexWord(rest, i);
|
const m = /^\.\d+/.exec(rest)!;
|
||||||
|
out.push({ type: "num", raw: m[0], start: i, end: i + m[0].length, value: new Decimal(m[0]), repr: "decimal" });
|
||||||
|
i += m[0].length;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lex) {
|
const word = WORD_RE.exec(rest);
|
||||||
out.push(lex);
|
if (word) {
|
||||||
i += lex.raw.length;
|
out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length });
|
||||||
|
i += word[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { parseLine } from "./parser";
|
|
||||||
import { tokenize } from "./tokenizer";
|
|
||||||
import { buildRegistry } from "./registry";
|
|
||||||
import { Decimal } from "./types";
|
|
||||||
|
|
||||||
const reg = buildRegistry();
|
|
||||||
|
|
||||||
function parse(line: string, knownVars = new Set<string>()) {
|
|
||||||
const tokens = tokenize(line, reg);
|
|
||||||
return parseLine(tokens, knownVars, line);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("parseLine", () => {
|
|
||||||
it("parses basic expressions", () => {
|
|
||||||
const res = parse("2 + 2");
|
|
||||||
expect(res.assign).toBeUndefined();
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "bin",
|
|
||||||
op: "plus",
|
|
||||||
l: { k: "num", v: new Decimal(2) },
|
|
||||||
r: { k: "num", v: new Decimal(2) }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses variable assignment with '='", () => {
|
|
||||||
const res = parse("x = 5 + 3");
|
|
||||||
expect(res.assign).toBe("x");
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "bin",
|
|
||||||
op: "plus",
|
|
||||||
l: { k: "num", v: new Decimal(5) },
|
|
||||||
r: { k: "num", v: new Decimal(3) }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses variable assignment with 'is'", () => {
|
|
||||||
const res = parse("myVar is 10");
|
|
||||||
expect(res.assign).toBe("myVar");
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "num",
|
|
||||||
v: new Decimal(10)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid variable names for assignment", () => {
|
|
||||||
const res = parse("123 = 5");
|
|
||||||
expect(res.assign).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles known variables shadowing built-ins", () => {
|
|
||||||
// "m" is normally a unit (meters), but if it's a known variable, it's treated as a word
|
|
||||||
const res = parse("m = 5", new Set(["m"]));
|
|
||||||
expect(res.assign).toBe("m");
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "num",
|
|
||||||
v: new Decimal(5)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters noise words", () => {
|
|
||||||
// "what is 2 + 2" -> "what" and "is" are noise, leaving "2 + 2"
|
|
||||||
const res = parse("what is 2 + 2");
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "bin",
|
|
||||||
op: "plus"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps words after a conversion operator (e.g., timezones)", () => {
|
|
||||||
// "now in Europe/Berlin"
|
|
||||||
// "Europe/Berlin" should be kept as a timezone, not dropped as noise.
|
|
||||||
const res = parse("now in Europe/Berlin");
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "conv",
|
|
||||||
target: {
|
|
||||||
type: "tz"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses goal seek expressions", () => {
|
|
||||||
const res = parse("? * 1.2 = 1000");
|
|
||||||
expect(res.expr).toMatchObject({
|
|
||||||
k: "goalseek",
|
|
||||||
lhs: { k: "bin", op: "mul", l: { k: "unknown" }, r: { k: "num", v: new Decimal(1.2) } },
|
|
||||||
rhs: { k: "num", v: new Decimal(1000) }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null expression for empty or noise-only lines", () => {
|
|
||||||
const res = parse("just some noise");
|
|
||||||
expect(res.expr).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { PctOp, DateWord, BitOp } from "./registry";
|
||||||
|
|
||||||
export type ConvTarget =
|
export type ConvTarget =
|
||||||
| { type: "unit"; unit: Unit }
|
| { type: "unit"; unit: Unit }
|
||||||
| { type: "currency"; code: string; onDate?: string }
|
| { type: "currency"; code: string }
|
||||||
| { type: "repr"; repr: NumeralRepr }
|
| { type: "repr"; repr: NumeralRepr }
|
||||||
| { type: "tz"; words: string[] }
|
| { type: "tz"; words: string[] }
|
||||||
| { type: "scale"; mult: Decimal; label: string }
|
| { type: "scale"; mult: Decimal; label: string }
|
||||||
|
|
@ -19,9 +19,7 @@ export type Node =
|
||||||
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
|
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
|
||||||
| { k: "var"; name: string }
|
| { k: "var"; name: string }
|
||||||
| { k: "date"; word: DateWord }
|
| { k: "date"; word: DateWord }
|
||||||
| { k: "datelit"; ms: number }
|
| { k: "agg"; name: "sum" | "avg" | "prev" }
|
||||||
| { k: "xref"; sheet: string; key: string }
|
|
||||||
| { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" }
|
|
||||||
| { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
|
| { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
|
||||||
| { k: "bit"; op: BitOp; l: Node; r: Node }
|
| { k: "bit"; op: BitOp; l: Node; r: Node }
|
||||||
| { k: "pctop"; op: PctOp; l: Node; r: Node }
|
| { k: "pctop"; op: PctOp; l: Node; r: Node }
|
||||||
|
|
@ -33,9 +31,7 @@ export type Node =
|
||||||
| { k: "scale"; x: Node; mult: Decimal; label: string }
|
| { k: "scale"; x: Node; mult: Decimal; label: string }
|
||||||
| { k: "conv"; x: Node; target: ConvTarget }
|
| { k: "conv"; x: Node; target: ConvTarget }
|
||||||
| { k: "call"; name: string; args: Node[] }
|
| { k: "call"; name: string; args: Node[] }
|
||||||
| { k: "seq"; items: Node[] }
|
| { k: "seq"; items: Node[] };
|
||||||
| { k: "unknown" }
|
|
||||||
| { k: "goalseek"; lhs: Node; rhs: Node };
|
|
||||||
|
|
||||||
export interface ParsedLine {
|
export interface ParsedLine {
|
||||||
assign?: string;
|
assign?: string;
|
||||||
|
|
@ -62,18 +58,6 @@ export function parseLine(tokens: Token[], knownVars: Set<string>, line: string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = filterNoiseTokens(toks, knownVars);
|
|
||||||
|
|
||||||
const goalSeek = tryParseGoalSeek(filtered, knownVars);
|
|
||||||
if (goalSeek) {
|
|
||||||
return { expr: goalSeek };
|
|
||||||
}
|
|
||||||
|
|
||||||
const p = new Parser(filtered, knownVars);
|
|
||||||
return { assign, expr: p.parseSeq() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
|
|
||||||
// Noise filtering: drop junk and unknown words, but keep words that follow
|
// Noise filtering: drop junk and unknown words, but keep words that follow
|
||||||
// a conversion operator (potential timezone names).
|
// a conversion operator (potential timezone names).
|
||||||
const filtered: Token[] = [];
|
const filtered: Token[] = [];
|
||||||
|
|
@ -84,43 +68,14 @@ function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
|
||||||
if (knownVars.has(tk.raw) || afterConv) filtered.push(tk);
|
if (knownVars.has(tk.raw) || afterConv) filtered.push(tk);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// a bare "/" inside a timezone name ("Europe/Berlin") must not break the
|
|
||||||
// word-keeping chain, otherwise the second half gets filtered as noise
|
|
||||||
if (afterConv && tk.t === "op" && tk.op === "div") {
|
|
||||||
filtered.push(tk);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
afterConv = tk.t === "conv";
|
afterConv = tk.t === "conv";
|
||||||
filtered.push(tk);
|
filtered.push(tk);
|
||||||
}
|
}
|
||||||
return filtered;
|
|
||||||
|
const p = new Parser(filtered, knownVars);
|
||||||
|
return { assign, expr: p.parseSeq() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | null {
|
|
||||||
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
|
|
||||||
const hasUnknown = filtered.some((tk) => tk.t === "unknown");
|
|
||||||
if (hasUnknown) {
|
|
||||||
const assignIdx = filtered.findIndex((tk) => tk.t === "assign");
|
|
||||||
if (assignIdx >= 0) {
|
|
||||||
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
|
|
||||||
const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq();
|
|
||||||
if (lhs && rhs) return { k: "goalseek", lhs, rhs };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const PREC = {
|
|
||||||
CONV: 10,
|
|
||||||
BIT: 20,
|
|
||||||
ADD: 30,
|
|
||||||
MUL: 40,
|
|
||||||
UNARY: 50,
|
|
||||||
POW: 55,
|
|
||||||
POSTFIX: 60,
|
|
||||||
};
|
|
||||||
|
|
||||||
class Parser {
|
class Parser {
|
||||||
private i = 0;
|
private i = 0;
|
||||||
constructor(private toks: Token[], private vars: Set<string>) {}
|
constructor(private toks: Token[], private vars: Set<string>) {}
|
||||||
|
|
@ -146,7 +101,7 @@ class Parser {
|
||||||
this.i++;
|
this.i++;
|
||||||
const target = this.parseTarget();
|
const target = this.parseTarget();
|
||||||
if (target) {
|
if (target) {
|
||||||
items = [{ k: "conv", x: mk(), target: this.tryHistoricalDate(target) }];
|
items = [{ k: "conv", x: mk(), target }];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.i = save + 1; // drop the conversion word, it was noise
|
this.i = save + 1; // drop the conversion word, it was noise
|
||||||
|
|
@ -154,7 +109,7 @@ class Parser {
|
||||||
}
|
}
|
||||||
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) && items.length > 0) {
|
if (tk.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) && items.length > 0) {
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.ADD);
|
const r = this.parseAdd();
|
||||||
if (r) {
|
if (r) {
|
||||||
items = [{ k: "pctop", op: tk.op, l: mk(), r }];
|
items = [{ k: "pctop", op: tk.op, l: mk(), r }];
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -165,7 +120,7 @@ class Parser {
|
||||||
if (tk.t === "op" && items.length > 0) {
|
if (tk.t === "op" && items.length > 0) {
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = tk.op === "plus" || tk.op === "minus" ? this.parseExpr(PREC.MUL) : this.parseExpr(PREC.UNARY);
|
const r = tk.op === "plus" || tk.op === "minus" ? this.parseMul() : this.parseUnary();
|
||||||
if (r) {
|
if (r) {
|
||||||
items = [{ k: "bin", op: tk.op, l: mk(), r }];
|
items = [{ k: "bin", op: tk.op, l: mk(), r }];
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -176,7 +131,7 @@ class Parser {
|
||||||
if (tk.t === "bitop" && items.length > 0) {
|
if (tk.t === "bitop" && items.length > 0) {
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const r = this.parseExpr(PREC.ADD);
|
const r = this.parseAdd();
|
||||||
if (r) {
|
if (r) {
|
||||||
items = [{ k: "bit", op: tk.op, l: mk(), r }];
|
items = [{ k: "bit", op: tk.op, l: mk(), r }];
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -185,7 +140,7 @@ class Parser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const before = this.i;
|
const before = this.i;
|
||||||
const node = this.parseExpr(PREC.BIT);
|
const node = this.parseBit();
|
||||||
if (node) items.push(node);
|
if (node) items.push(node);
|
||||||
if (this.i === before) this.i++; // skip unparsable token
|
if (this.i === before) this.i++; // skip unparsable token
|
||||||
}
|
}
|
||||||
|
|
@ -193,177 +148,50 @@ class Parser {
|
||||||
return mk();
|
return mk();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** bitwise level (one flat precedence, left-assoc): & | xor << >> */
|
||||||
* Pratt parser implementation for expressions.
|
private parseBit(): Node | null {
|
||||||
*/
|
let l = this.parseAdd();
|
||||||
private parseExpr(minPrec: number = 0): Node | null {
|
if (!l) return null;
|
||||||
let tk = this.peek();
|
for (;;) {
|
||||||
if (!tk) return null;
|
const tk = this.peek();
|
||||||
|
if (tk?.t !== "bitop") break;
|
||||||
let lhs: Node | null = null;
|
const save = this.i;
|
||||||
|
|
||||||
if (tk.t === "op" && tk.op === "minus") {
|
|
||||||
this.i++;
|
this.i++;
|
||||||
const x = this.parseExpr(PREC.UNARY);
|
const r = this.parseAdd();
|
||||||
if (!x) return null;
|
if (!r) {
|
||||||
lhs = { k: "neg", x };
|
this.i = save;
|
||||||
} else if (tk.t === "op" && tk.op === "plus") {
|
break;
|
||||||
this.i++;
|
}
|
||||||
lhs = this.parseExpr(PREC.UNARY);
|
l = { k: "bit", op: tk.op, l, r };
|
||||||
if (!lhs) return null;
|
}
|
||||||
} else {
|
return l;
|
||||||
lhs = this.parsePrimary();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lhs) return null;
|
/** conversion level: `x in cm`, `x as a % of y`, `x of what is y` */
|
||||||
|
private parseConv(): Node | null {
|
||||||
loop: for (;;) {
|
let l = this.parseBit();
|
||||||
tk = this.peek();
|
if (!l) return null;
|
||||||
if (!tk) break;
|
for (;;) {
|
||||||
|
const tk = this.peek();
|
||||||
switch (tk.t) {
|
if (tk?.t === "conv") {
|
||||||
case "percent": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "pct", x: lhs };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "bang": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "fact", x: lhs };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "unit": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "unit", x: lhs, unit: tk.unit };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "currency": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "curr", x: lhs, code: tk.code };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "scale": {
|
|
||||||
if (PREC.POSTFIX < minPrec) break loop;
|
|
||||||
this.i++;
|
|
||||||
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "op": {
|
|
||||||
switch (tk.op) {
|
|
||||||
case "pow": {
|
|
||||||
if (PREC.POW < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.POW);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: "pow", l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "mul":
|
|
||||||
case "div":
|
|
||||||
case "mod": {
|
|
||||||
if (PREC.MUL < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "plus":
|
|
||||||
case "minus": {
|
|
||||||
if (PREC.ADD < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.ADD + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break loop;
|
|
||||||
}
|
|
||||||
case "pctop": {
|
|
||||||
if (tk.op === "of" || tk.op === "off" || tk.op === "on") {
|
|
||||||
if (!(lhs.k === "pct" || lhs.k === "var" || lhs.k === "pctop")) {
|
|
||||||
this.i++;
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
if (PREC.MUL < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "pctop", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
if (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) {
|
|
||||||
if (PREC.CONV < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.ADD);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "pctop", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
break loop;
|
|
||||||
}
|
|
||||||
case "lparen":
|
|
||||||
case "const":
|
|
||||||
case "func":
|
|
||||||
case "word": {
|
|
||||||
if (tk.t === "word" && !this.vars.has(tk.raw)) break loop;
|
|
||||||
if (PREC.MUL < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
const r = this.parseExpr(PREC.MUL + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bin", op: "mul", l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "bitop": {
|
|
||||||
if (PREC.BIT < minPrec) break loop;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
|
||||||
const r = this.parseExpr(PREC.BIT + 1);
|
|
||||||
if (!r) { this.i = save; break loop; }
|
|
||||||
lhs = { k: "bit", op: tk.op, l: lhs, r };
|
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
case "conv": {
|
|
||||||
if (PREC.CONV < minPrec) break loop;
|
|
||||||
const save = this.i;
|
const save = this.i;
|
||||||
this.i++;
|
this.i++;
|
||||||
const target = this.parseTarget();
|
const target = this.parseTarget();
|
||||||
if (!target) { this.i = save; break loop; }
|
if (!target) {
|
||||||
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
|
this.i = save;
|
||||||
continue loop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return lhs;
|
l = { k: "conv", x: l, target };
|
||||||
}
|
} else if (tk?.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
|
||||||
/** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */
|
|
||||||
private tryHistoricalDate(target: ConvTarget): ConvTarget {
|
|
||||||
if (target.type !== "currency") return target;
|
|
||||||
const nx = this.peek();
|
|
||||||
if (nx?.t !== "pctop" || nx.op !== "on") return target;
|
|
||||||
const save = this.i;
|
|
||||||
this.i++;
|
this.i++;
|
||||||
const dk = this.peek();
|
const r = this.parseAdd();
|
||||||
if (dk?.t === "datelit") {
|
if (!r) break;
|
||||||
this.i++;
|
l = { k: "pctop", op: tk.op, l, r };
|
||||||
const d = new Date(dk.ms);
|
} else {
|
||||||
const onDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
break;
|
||||||
return { ...target, onDate };
|
|
||||||
}
|
}
|
||||||
this.i = save;
|
}
|
||||||
return target;
|
return l;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseTarget(): ConvTarget | null {
|
private parseTarget(): ConvTarget | null {
|
||||||
|
|
@ -395,24 +223,130 @@ class Parser {
|
||||||
}
|
}
|
||||||
if (tk.t === "word") {
|
if (tk.t === "word") {
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
for (;;) {
|
while (this.peek()?.t === "word" && words.length < 4) {
|
||||||
const w = this.peek();
|
|
||||||
if (w?.t === "word" && words.length < 4) {
|
|
||||||
words.push((this.next() as Token & { raw: string }).raw);
|
words.push((this.next() as Token & { raw: string }).raw);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// "Europe/Berlin": a slash between timezone words is part of the name
|
|
||||||
if (w?.t === "op" && w.op === "div" && words.length > 0 && this.toks[this.i + 1]?.t === "word") {
|
|
||||||
this.next();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
if (words.length > 0) return { type: "tz", words };
|
if (words.length > 0) return { type: "tz", words };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseAdd(): Node | null {
|
||||||
|
let l = this.parseMul();
|
||||||
|
if (!l) return null;
|
||||||
|
for (;;) {
|
||||||
|
const tk = this.peek();
|
||||||
|
if (tk?.t === "op" && (tk.op === "plus" || tk.op === "minus")) {
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseMul();
|
||||||
|
if (!r) {
|
||||||
|
this.i = save;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
l = { k: "bin", op: tk.op, l, r };
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMul(): Node | null {
|
||||||
|
let l = this.parseUnary();
|
||||||
|
if (!l) return null;
|
||||||
|
for (;;) {
|
||||||
|
const tk = this.peek();
|
||||||
|
if (tk?.t === "op" && (tk.op === "mul" || tk.op === "div" || tk.op === "mod")) {
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseUnary();
|
||||||
|
if (!r) {
|
||||||
|
this.i = save;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
l = { k: "bin", op: tk.op, l, r };
|
||||||
|
} else if (tk?.t === "pctop" && (tk.op === "of" || tk.op === "off" || tk.op === "on")) {
|
||||||
|
// of/off/on only make sense with a percent on the left ("20% of x");
|
||||||
|
// otherwise the word is prose noise ("5 on coffee")
|
||||||
|
if (!(l.k === "pct" || l.k === "var" || l.k === "pctop")) {
|
||||||
|
this.i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const save = this.i;
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseUnary();
|
||||||
|
if (!r) {
|
||||||
|
this.i = save;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
l = { k: "pctop", op: tk.op, l, r };
|
||||||
|
} else if (
|
||||||
|
tk &&
|
||||||
|
(tk.t === "lparen" || tk.t === "const" || tk.t === "func" || (tk.t === "word" && this.vars.has(tk.raw)))
|
||||||
|
) {
|
||||||
|
// implicit multiplication: 2(3+4), 2pi, 2x
|
||||||
|
const save = this.i;
|
||||||
|
const r = this.parseUnary();
|
||||||
|
if (!r) {
|
||||||
|
this.i = save;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
l = { k: "bin", op: "mul", l, r };
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnary(): Node | null {
|
||||||
|
const tk = this.peek();
|
||||||
|
if (tk?.t === "op" && tk.op === "minus") {
|
||||||
|
this.i++;
|
||||||
|
const x = this.parseUnary();
|
||||||
|
return x ? { k: "neg", x } : null;
|
||||||
|
}
|
||||||
|
if (tk?.t === "op" && tk.op === "plus") {
|
||||||
|
this.i++;
|
||||||
|
return this.parseUnary();
|
||||||
|
}
|
||||||
|
return this.parsePostfix();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePostfix(): Node | null {
|
||||||
|
let x = this.parsePrimary();
|
||||||
|
if (!x) return null;
|
||||||
|
for (;;) {
|
||||||
|
const tk = this.peek();
|
||||||
|
if (!tk) break;
|
||||||
|
if (tk.t === "percent") {
|
||||||
|
this.i++;
|
||||||
|
x = { k: "pct", x };
|
||||||
|
} else if (tk.t === "bang") {
|
||||||
|
this.i++;
|
||||||
|
x = { k: "fact", x };
|
||||||
|
} else if (tk.t === "unit") {
|
||||||
|
this.i++;
|
||||||
|
x = { k: "unit", x, unit: tk.unit };
|
||||||
|
} else if (tk.t === "currency") {
|
||||||
|
this.i++;
|
||||||
|
x = { k: "curr", x, code: tk.code };
|
||||||
|
} else if (tk.t === "scale") {
|
||||||
|
this.i++;
|
||||||
|
x = { k: "scale", x, mult: tk.mult, label: "" };
|
||||||
|
} else if (tk.t === "op" && tk.op === "pow") {
|
||||||
|
this.i++;
|
||||||
|
const r = this.parseUnary();
|
||||||
|
if (!r) break;
|
||||||
|
x = { k: "bin", op: "pow", l: x, r };
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
private parsePrimary(): Node | null {
|
private parsePrimary(): Node | null {
|
||||||
const tk = this.peek();
|
const tk = this.peek();
|
||||||
if (!tk) return null;
|
if (!tk) return null;
|
||||||
|
|
@ -426,12 +360,6 @@ class Parser {
|
||||||
case "date":
|
case "date":
|
||||||
this.i++;
|
this.i++;
|
||||||
return { k: "date", word: tk.word };
|
return { k: "date", word: tk.word };
|
||||||
case "datelit":
|
|
||||||
this.i++;
|
|
||||||
return { k: "datelit", ms: tk.ms };
|
|
||||||
case "xref":
|
|
||||||
this.i++;
|
|
||||||
return { k: "xref", sheet: tk.sheet, key: tk.key };
|
|
||||||
case "agg":
|
case "agg":
|
||||||
this.i++;
|
this.i++;
|
||||||
return { k: "agg", name: tk.name };
|
return { k: "agg", name: tk.name };
|
||||||
|
|
@ -448,7 +376,7 @@ class Parser {
|
||||||
this.i++;
|
this.i++;
|
||||||
const args: Node[] = [];
|
const args: Node[] = [];
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const arg = this.parseExpr(PREC.CONV);
|
const arg = this.parseConv();
|
||||||
if (arg) args.push(arg);
|
if (arg) args.push(arg);
|
||||||
const nx = this.peek();
|
const nx = this.peek();
|
||||||
if (nx?.t === "semicolon") {
|
if (nx?.t === "semicolon") {
|
||||||
|
|
@ -469,12 +397,12 @@ class Parser {
|
||||||
// `sqrt 16` / `square root of 16` — the "of" is decorative
|
// `sqrt 16` / `square root of 16` — the "of" is decorative
|
||||||
const nx = this.peek();
|
const nx = this.peek();
|
||||||
if (nx?.t === "pctop" && nx.op === "of") this.i++;
|
if (nx?.t === "pctop" && nx.op === "of") this.i++;
|
||||||
const arg = this.parseExpr(PREC.UNARY);
|
const arg = this.parseUnary();
|
||||||
return arg ? { k: "call", name, args: [arg] } : null;
|
return arg ? { k: "call", name, args: [arg] } : null;
|
||||||
}
|
}
|
||||||
case "lparen": {
|
case "lparen": {
|
||||||
this.i++;
|
this.i++;
|
||||||
const inner = this.parseExpr(PREC.CONV);
|
const inner = this.parseConv();
|
||||||
if (this.peek()?.t === "rparen") this.i++;
|
if (this.peek()?.t === "rparen") this.i++;
|
||||||
return inner;
|
return inner;
|
||||||
}
|
}
|
||||||
|
|
@ -487,9 +415,6 @@ class Parser {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
case "unknown":
|
|
||||||
this.i++;
|
|
||||||
return { k: "unknown" };
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { buildRegistry, Registry } from "./registry";
|
|
||||||
import { lexLine } from "./lexer";
|
|
||||||
import { Decimal } from "./types";
|
|
||||||
|
|
||||||
describe("registry", () => {
|
|
||||||
let reg: Registry;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
reg = buildRegistry();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildRegistry", () => {
|
|
||||||
it("initializes a valid Registry instance with core vocabulary", () => {
|
|
||||||
// Check core words
|
|
||||||
const plusPayload = reg.match(lexLine("plus"), ["plus"], 0);
|
|
||||||
expect(plusPayload).not.toBeNull();
|
|
||||||
expect(plusPayload?.payload.t).toBe("op");
|
|
||||||
if (plusPayload?.payload.t === "op") expect(plusPayload.payload.op).toBe("plus");
|
|
||||||
|
|
||||||
const piPayload = reg.match(lexLine("pi"), ["pi"], 0);
|
|
||||||
expect(piPayload).not.toBeNull();
|
|
||||||
expect(piPayload?.payload.t).toBe("const");
|
|
||||||
if (piPayload?.payload.t === "const") expect(piPayload.payload.name).toBe("pi");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers built-in functions", () => {
|
|
||||||
expect(reg.isFunc("sin")).toBe(true);
|
|
||||||
expect(reg.isFunc("sqrt")).toBe(true);
|
|
||||||
expect(reg.isFunc("random")).toBe(true);
|
|
||||||
expect(reg.isFunc("unknownFunc")).toBe(false);
|
|
||||||
|
|
||||||
const sinPayload = reg.match(lexLine("sin"), ["sin"], 0);
|
|
||||||
expect(sinPayload).not.toBeNull();
|
|
||||||
expect(sinPayload?.payload.t).toBe("func");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers common units", () => {
|
|
||||||
// "meter"
|
|
||||||
const meterPayload = reg.match(lexLine("meter"), ["meter"], 0);
|
|
||||||
expect(meterPayload).not.toBeNull();
|
|
||||||
expect(meterPayload?.payload.t).toBe("unit");
|
|
||||||
|
|
||||||
// "kg" (strict/lenient)
|
|
||||||
const kgPayload = reg.match(lexLine("kg"), ["kg"], 0);
|
|
||||||
expect(kgPayload).not.toBeNull();
|
|
||||||
expect(kgPayload?.payload.t).toBe("unit");
|
|
||||||
|
|
||||||
// "USD" (strict/lenient currency)
|
|
||||||
const usdPayload = reg.match(lexLine("USD"), ["usd"], 0);
|
|
||||||
expect(usdPayload).not.toBeNull();
|
|
||||||
expect(usdPayload?.payload.t).toBe("currency");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("match", () => {
|
|
||||||
it("matches single-word phrases", () => {
|
|
||||||
const match = reg.match(lexLine("usd"), ["usd"], 0);
|
|
||||||
expect(match).not.toBeNull();
|
|
||||||
expect(match?.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches multi-word phrases", () => {
|
|
||||||
const match = reg.match(lexLine("square meter"), ["square", "meter"], 0);
|
|
||||||
expect(match).not.toBeNull();
|
|
||||||
expect(match?.length).toBe(2);
|
|
||||||
expect(match?.payload.t).toBe("unit");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects case-sensitivity", () => {
|
|
||||||
// "m" (milli) vs "M" (mega)
|
|
||||||
const mMatch = reg.match(lexLine("mm"), ["mm"], 0);
|
|
||||||
const mPayload = mMatch?.payload;
|
|
||||||
expect(mPayload?.t).toBe("unit");
|
|
||||||
|
|
||||||
const MMatch = reg.match(lexLine("Mm"), ["mm"], 0);
|
|
||||||
const MPayload = MMatch?.payload;
|
|
||||||
expect(MPayload?.t).toBe("unit");
|
|
||||||
|
|
||||||
if (mPayload?.t === "unit" && MPayload?.t === "unit") {
|
|
||||||
expect(mPayload.unit.id).not.toBe(MPayload.unit.id);
|
|
||||||
expect(mPayload.unit.id).toBe("milli:meter");
|
|
||||||
expect(MPayload.unit.id).toBe("mega:meter");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects mustTouch flag", () => {
|
|
||||||
// "log2" vs "log 2"
|
|
||||||
const lex2 = lexLine("log2");
|
|
||||||
const log2Match = reg.match(lex2, lex2.map(l => l.raw), 0);
|
|
||||||
expect(log2Match).not.toBeNull();
|
|
||||||
expect(log2Match?.payload.t).toBe("func");
|
|
||||||
if (log2Match?.payload.t === "func") expect(log2Match.payload.name).toBe("log2");
|
|
||||||
|
|
||||||
const lexSpace2 = lexLine("log 2");
|
|
||||||
const logSpace2Match = reg.match(lexSpace2, lexSpace2.map(l => l.raw), 0);
|
|
||||||
// "log 2" should match "log", length 1, not "log2"
|
|
||||||
expect(logSpace2Match).not.toBeNull();
|
|
||||||
expect(logSpace2Match?.length).toBe(1);
|
|
||||||
if (logSpace2Match?.payload.t === "func") expect(logSpace2Match.payload.name).toBe("log");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for no match", () => {
|
|
||||||
const match = reg.match(lexLine("unknownword"), ["unknownword"], 0);
|
|
||||||
expect(match).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("currency and rates", () => {
|
|
||||||
it("sets and updates conversion rates", () => {
|
|
||||||
reg.setRates({ "EUR": 0.9, "GBP": "0.8" });
|
|
||||||
expect(reg.rates.get("EUR")?.toNumber()).toBe(0.9);
|
|
||||||
expect(reg.rates.get("GBP")?.toNumber()).toBe(0.8);
|
|
||||||
// USD is implicitly set to 1
|
|
||||||
expect(reg.rates.get("USD")?.toNumber()).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores negative rates", () => {
|
|
||||||
reg.setRates({ "EUR": 0.9, "INVALID": -1 });
|
|
||||||
expect(reg.rates.get("EUR")?.toNumber()).toBe(0.9);
|
|
||||||
expect(reg.rates.has("INVALID")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates currency unit from rate", () => {
|
|
||||||
const unit = reg.makeCurrencyUnitFromRate("EUR", new Decimal(0.9));
|
|
||||||
expect(unit.id).toBe("EUR");
|
|
||||||
expect(unit.dimension).toBe("currency");
|
|
||||||
// 1 / 0.9 = 1.111...
|
|
||||||
expect(unit.ratio.toNumber()).toBeCloseTo(1.1111, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("makes currency unit if rate exists", () => {
|
|
||||||
reg.setRates({ "EUR": 0.9 });
|
|
||||||
const unit = reg.makeCurrencyUnit("EUR");
|
|
||||||
expect(unit).not.toBeNull();
|
|
||||||
expect(unit?.id).toBe("EUR");
|
|
||||||
|
|
||||||
const noUnit = reg.makeCurrencyUnit("JPY");
|
|
||||||
expect(noUnit).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("completions", () => {
|
|
||||||
it("populates completions with expected categories", () => {
|
|
||||||
expect(reg.completions.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const types = new Set(reg.completions.map(c => c.type));
|
|
||||||
expect(types.has("unit")).toBe(true);
|
|
||||||
expect(types.has("currency")).toBe(true);
|
|
||||||
expect(types.has("function")).toBe(true);
|
|
||||||
expect(types.has("keyword")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("contains specific known completions", () => {
|
|
||||||
const labels = reg.completions.map(c => c.label);
|
|
||||||
expect(labels).toContain("USD");
|
|
||||||
expect(labels).toContain("meter");
|
|
||||||
expect(labels).toContain("sin");
|
|
||||||
expect(labels).toContain("sum");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -8,7 +8,6 @@ import * as vocab from "./vocab";
|
||||||
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
||||||
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
|
import { EXTRA_UNITS, CRYPTO } from "./extraunits";
|
||||||
|
|
||||||
/** Operators for percentage calculations. */
|
|
||||||
export type PctOp =
|
export type PctOp =
|
||||||
| "of" | "off" | "on"
|
| "of" | "off" | "on"
|
||||||
| "as_pct_of" | "as_pct_off" | "as_pct_on"
|
| "as_pct_of" | "as_pct_off" | "as_pct_on"
|
||||||
|
|
@ -28,7 +27,7 @@ export type Payload =
|
||||||
| { t: "pctop"; op: PctOp }
|
| { t: "pctop"; op: PctOp }
|
||||||
| { t: "percent" }
|
| { t: "percent" }
|
||||||
| { t: "func"; name: string }
|
| { t: "func"; name: string }
|
||||||
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" }
|
| { t: "agg"; name: "sum" | "avg" | "prev" }
|
||||||
| { t: "scale"; mult: Decimal }
|
| { t: "scale"; mult: Decimal }
|
||||||
| { t: "repr"; repr: NumeralRepr }
|
| { t: "repr"; repr: NumeralRepr }
|
||||||
| { t: "special"; name: "unix" | "todate" }
|
| { t: "special"; name: "unix" | "todate" }
|
||||||
|
|
@ -59,7 +58,7 @@ const BUILTIN_FUNCS = [
|
||||||
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
|
"sqrt", "cbrt", "sin", "cos", "tan", "cot", "asin", "acos", "atan",
|
||||||
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
|
"arcsin", "arccos", "arctan", "sinh", "cosh", "tanh",
|
||||||
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
|
"ln", "lg", "log", "log2", "exp", "abs", "round", "ceil", "floor",
|
||||||
"fact", "factorial", "random", "until", "since",
|
"fact", "factorial",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class Registry {
|
export class Registry {
|
||||||
|
|
@ -135,14 +134,10 @@ export class Registry {
|
||||||
makeCurrencyUnit(code: string): Unit | null {
|
makeCurrencyUnit(code: string): Unit | null {
|
||||||
const rate = this.rates.get(code);
|
const rate = this.rates.get(code);
|
||||||
if (!rate) return null;
|
if (!rate) return null;
|
||||||
return this.makeCurrencyUnitFromRate(code, rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCurrencyUnitFromRate(code: string, ratePerUsd: Decimal): Unit {
|
|
||||||
return {
|
return {
|
||||||
id: code,
|
id: code,
|
||||||
dimension: "currency",
|
dimension: "currency",
|
||||||
ratio: new Decimal(1).div(ratePerUsd), // USD per 1 unit
|
ratio: new Decimal(1).div(rate), // USD per 1 unit
|
||||||
format: this.currencyFormat.get(code) ?? `{} ${code}`,
|
format: this.currencyFormat.get(code) ?? `{} ${code}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -157,50 +152,30 @@ function addUnitPhrases(reg: Registry, phrases: string[], unit: Unit, opts: { ca
|
||||||
for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts);
|
for (const p of phrases) reg.addPhrase(p, { t: "unit", unit }, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
|
|
||||||
|
|
||||||
export function buildRegistry(): Registry {
|
export function buildRegistry(): Registry {
|
||||||
const reg = new Registry();
|
const reg = new Registry();
|
||||||
registerCoreVocab(reg);
|
|
||||||
const lengthPhrases = registerUnits(reg);
|
|
||||||
registerAreaVolume(reg, lengthPhrases);
|
|
||||||
registerCurrencies(reg);
|
|
||||||
registerExtraUnits(reg);
|
|
||||||
registerCrypto(reg);
|
|
||||||
registerCompletions(reg);
|
|
||||||
return reg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// operators, percent, dates, numerals, scales, aggregates, built-in functions
|
// operators, percent, dates, numerals, scales, aggregates
|
||||||
function registerCoreVocab(reg: Registry): void {
|
|
||||||
const ops: Array<[string, Payload]> = [
|
const ops: Array<[string, Payload]> = [
|
||||||
["plus", { t: "op", op: "plus" }],
|
["plus", { t: "op", op: "plus" }],
|
||||||
["minus", { t: "op", op: "minus" }],
|
["minus", { t: "op", op: "minus" }],
|
||||||
["multiply", { t: "op", op: "mul" }],
|
["multiply", { t: "op", op: "mul" }],
|
||||||
["divide", { t: "op", op: "div" }],
|
["divide", { t: "op", op: "div" }],
|
||||||
["conversion", { t: "conv" }],
|
|
||||||
["assignment", { t: "assign" }],
|
|
||||||
["sum", { t: "agg", name: "sum" }],
|
|
||||||
["avg", { t: "agg", name: "avg" }],
|
|
||||||
["prev", { t: "agg", name: "prev" }],
|
|
||||||
["count", { t: "agg", name: "count" }],
|
|
||||||
["min", { t: "agg", name: "min" }],
|
|
||||||
["max", { t: "agg", name: "max" }],
|
|
||||||
["product", { t: "agg", name: "product" }],
|
|
||||||
["chart", { t: "agg", name: "chart" }],
|
|
||||||
["sqrt", { t: "func", name: "sqrt" }],
|
|
||||||
["cbrt", { t: "func", name: "cbrt" }],
|
|
||||||
["abs", { t: "func", name: "abs" }],
|
|
||||||
["ln", { t: "func", name: "ln" }],
|
|
||||||
["log", { t: "func", name: "log" }],
|
|
||||||
["round", { t: "func", name: "round" }],
|
|
||||||
["random", { t: "func", name: "random" }],
|
|
||||||
["until", { t: "func", name: "until" }],
|
|
||||||
["since", { t: "func", name: "since" }],
|
|
||||||
];
|
];
|
||||||
for (const [key, payload] of ops) {
|
for (const [key, payload] of ops) {
|
||||||
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
|
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
|
||||||
}
|
}
|
||||||
|
for (const v of vocab.variants("Operations", "conversion.variants")) reg.addPhrase(v, { t: "conv" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "assignment.variants")) reg.addPhrase(v, { t: "assign" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
|
||||||
|
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
|
||||||
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });
|
||||||
|
|
||||||
|
|
@ -268,11 +243,9 @@ function registerCoreVocab(reg: Registry): void {
|
||||||
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
|
reg.addPhrase("полторы", { t: "const", name: "onehalf" }, { caseSensitive: false });
|
||||||
|
|
||||||
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
|
for (const f of BUILTIN_FUNCS) reg.addPhrase(f, { t: "func", name: f }, { caseSensitive: false });
|
||||||
}
|
|
||||||
|
|
||||||
// UNIT_DATA + SI/IEC prefixes; returns the length-dimension phrases the
|
// units, SI/IEC prefixes, area/volume templates
|
||||||
// area/volume templates need to build "square meter" / "cubic foot" etc.
|
interface LengthPhrase { phrase: string; unit: Unit; caseSensitive?: boolean }
|
||||||
function registerUnits(reg: Registry): LengthPhrase[] {
|
|
||||||
const lengthPhrases: LengthPhrase[] = [];
|
const lengthPhrases: LengthPhrase[] = [];
|
||||||
/** lowercased composed symbol -> unit; null = ambiguous (mm vs Mm) */
|
/** lowercased composed symbol -> unit; null = ambiguous (mm vs Mm) */
|
||||||
const lenientSym = new Map<string, Unit | null>();
|
const lenientSym = new Map<string, Unit | null>();
|
||||||
|
|
@ -350,11 +323,7 @@ function registerUnits(reg: Registry): LengthPhrase[] {
|
||||||
if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });
|
if (unit) reg.addPhrase(lower, { t: "unit", unit }, { caseSensitive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
return lengthPhrases;
|
// Area/volume from templates: "square %@" / "cubic %@" applied to every length phrase
|
||||||
}
|
|
||||||
|
|
||||||
// "square %@" / "cubic %@" applied to every length phrase from registerUnits
|
|
||||||
function registerAreaVolume(reg: Registry, lengthPhrases: LengthPhrase[]): void {
|
|
||||||
const applyTemplates = (
|
const applyTemplates = (
|
||||||
templates: string[],
|
templates: string[],
|
||||||
fmtTemplate: string,
|
fmtTemplate: string,
|
||||||
|
|
@ -382,11 +351,10 @@ function registerAreaVolume(reg: Registry, lengthPhrases: LengthPhrase[]): void
|
||||||
};
|
};
|
||||||
applyTemplates(vocab.variants("Area", "area.templates"), vocab.entryEn("Area", "area.format") ?? "%@²", "area", 2, "sq");
|
applyTemplates(vocab.variants("Area", "area.templates"), vocab.entryEn("Area", "area.format") ?? "%@²", "area", 2, "sq");
|
||||||
applyTemplates(vocab.variants("Volume", "volume.templates"), vocab.entryEn("Volume", "volume.format") ?? "%@³", "volume", 3, "cb");
|
applyTemplates(vocab.variants("Volume", "volume.templates"), vocab.entryEn("Volume", "volume.format") ?? "%@³", "volume", 3, "cb");
|
||||||
}
|
|
||||||
|
|
||||||
// Major currencies register first so they win generic phrases
|
// currencies
|
||||||
// ("доллар" belongs to USD, not TTD which lists it too and sorts earlier).
|
// Major currencies register first so they win generic phrases
|
||||||
function registerCurrencies(reg: Registry): void {
|
// ("доллар" belongs to USD, not TTD which lists it too and sorts earlier).
|
||||||
const PRIORITY = [
|
const PRIORITY = [
|
||||||
"USD", "EUR", "GBP", "RUB", "JPY", "CNY", "CHF", "CAD", "AUD", "UAH",
|
"USD", "EUR", "GBP", "RUB", "JPY", "CNY", "CHF", "CAD", "AUD", "UAH",
|
||||||
"BYN", "KZT", "PLN", "TRY", "INR", "BRL", "KRW", "NZD", "SEK", "NOK",
|
"BYN", "KZT", "PLN", "TRY", "INR", "BRL", "KRW", "NZD", "SEK", "NOK",
|
||||||
|
|
@ -405,10 +373,8 @@ function registerCurrencies(reg: Registry): void {
|
||||||
if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code);
|
if (!variants.some((v) => v.toUpperCase() === code)) variants.push(code);
|
||||||
for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });
|
for (const v of variants) reg.addPhrase(v, { t: "currency", code }, { caseSensitive: false });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// speed, pressure, energy, power, frequency, fuel
|
// speed, pressure, energy, power, frequency, fuel
|
||||||
function registerExtraUnits(reg: Registry): void {
|
|
||||||
for (const d of EXTRA_UNITS) {
|
for (const d of EXTRA_UNITS) {
|
||||||
const unit: Unit = {
|
const unit: Unit = {
|
||||||
id: d.id,
|
id: d.id,
|
||||||
|
|
@ -429,11 +395,9 @@ function registerExtraUnits(reg: Registry): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// live prices come from the shell; codes that collide with common words
|
// cryptocurrencies (live prices come from the shell)
|
||||||
// (TON, NEAR, DOT, ...) match case-sensitively only
|
// codes that collide with common words match case-sensitively only
|
||||||
function registerCrypto(reg: Registry): void {
|
|
||||||
const CRYPTO_STRICT_CODES = new Set(["TON", "NEAR", "ATOM", "LINK", "UNI", "DOT", "SOL"]);
|
const CRYPTO_STRICT_CODES = new Set(["TON", "NEAR", "ATOM", "LINK", "UNI", "DOT", "SOL"]);
|
||||||
for (const c of CRYPTO) {
|
for (const c of CRYPTO) {
|
||||||
reg.currencyCodes.add(c.code);
|
reg.currencyCodes.add(c.code);
|
||||||
|
|
@ -444,9 +408,8 @@ function registerCrypto(reg: Registry): void {
|
||||||
if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });
|
if (ph) reg.addPhrase(ph, { t: "currency", code: c.code }, { caseSensitive: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function registerCompletions(reg: Registry): void {
|
// autocomplete entries
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
|
const addCompletion = (label: string, type: Completion["type"], detail?: string) => {
|
||||||
const k = `${type}:${label.toLowerCase()}`;
|
const k = `${type}:${label.toLowerCase()}`;
|
||||||
|
|
@ -465,4 +428,6 @@ function registerCompletions(reg: Registry): void {
|
||||||
for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
|
for (const k of ["sum", "total", "average", "avg", "prev", "today", "tomorrow", "yesterday", "time", "now", "hex", "binary"]) {
|
||||||
addCompletion(k, "keyword");
|
addCompletion(k, "keyword");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return reg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { describe, it, expect, beforeAll } from "vitest";
|
|
||||||
import { tokenize } from "./tokenizer";
|
|
||||||
import { buildRegistry, Registry } from "./registry";
|
|
||||||
|
|
||||||
describe("tokenizer", () => {
|
|
||||||
let reg: Registry;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
reg = buildRegistry();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tokenizes numbers and basic operators", () => {
|
|
||||||
const tokens = tokenize("1 + 2 * 3.5 - 4 / 2 ^ 3", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual([
|
|
||||||
"num", "op", "num", "op", "num", "op", "num", "op", "num", "op", "num"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles dates", () => {
|
|
||||||
const tokens = tokenize("2024-01-01 + 5 days", reg);
|
|
||||||
expect(tokens[0].t).toBe("datelit");
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["datelit", "op", "num", "unit"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles xrefs", () => {
|
|
||||||
const tokens = tokenize("@Sheet.key", reg);
|
|
||||||
expect(tokens[0].t).toBe("xref");
|
|
||||||
if (tokens[0].t === "xref") {
|
|
||||||
expect(tokens[0].sheet).toBe("Sheet");
|
|
||||||
expect(tokens[0].key).toBe("key");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles bitwise operators", () => {
|
|
||||||
const tokens = tokenize("5 << 1 >> 2 & 3 | 4", reg);
|
|
||||||
const typesAndOps = tokens.map(t => t.t === "bitop" ? `bitop:${t.op}` : t.t);
|
|
||||||
expect(typesAndOps).toEqual([
|
|
||||||
"num", "bitop:shl", "num", "bitop:shr", "num", "bitop:band", "num", "bitop:bor", "num"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles registry matches like units and currencies", () => {
|
|
||||||
const tokens = tokenize("100 USD to EUR", reg);
|
|
||||||
const types = tokens.map(t => t.t);
|
|
||||||
expect(types).toEqual(["num", "currency", "conv", "currency"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disambiguates 'in' as inches", () => {
|
|
||||||
const tokens = tokenize("5 ft 4 in in cm", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["num", "unit", "num", "unit", "conv", "unit"]);
|
|
||||||
if (tokens[3].t === "unit") {
|
|
||||||
expect(tokens[3].unit.id).toBe("inch");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not disambiguate 'in' when it is not inches", () => {
|
|
||||||
const tokens = tokenize("in cm", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["conv", "unit"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles other registry constructs (funcs, aggs, etc)", () => {
|
|
||||||
const tokens = tokenize("sum(1, 2) + sin(pi) %", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual([
|
|
||||||
"agg", "lparen", "num", "junk", "num", "rparen", "op", "func", "lparen", "const", "rparen", "percent"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles scale and repr", () => {
|
|
||||||
const tokens = tokenize("1M in hex", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["num", "scale", "conv", "repr"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles pctop and date words", () => {
|
|
||||||
const tokens = tokenize("5% of 100 today", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["num", "percent", "pctop", "num", "date"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles bang, semicolon, unknown", () => {
|
|
||||||
const tokens = tokenize("! ; ?", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["bang", "semicolon", "unknown"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles special tokens", () => {
|
|
||||||
const tokens = tokenize("unix date", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["special", "special"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles assign", () => {
|
|
||||||
const tokens = tokenize("x = 5", reg);
|
|
||||||
expect(tokens.map(t => t.t)).toEqual(["word", "assign", "num"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Lexes a line and resolves phrases through the registry into semantic tokens.
|
// Lexes a line and resolves phrases through the registry into semantic tokens.
|
||||||
import { DateOrder, Decimal, NumeralRepr, Unit } from "./types";
|
import { Decimal, NumeralRepr, Unit } from "./types";
|
||||||
import { lexLine } from "./lexer";
|
import { lexLine } from "./lexer";
|
||||||
import { Registry, PctOp, DateWord, BitOp } from "./registry";
|
import { Registry, PctOp, DateWord, BitOp } from "./registry";
|
||||||
|
|
||||||
|
|
@ -15,23 +15,23 @@ export type Token =
|
||||||
| { t: "pctop"; op: PctOp; start: number; end: number }
|
| { t: "pctop"; op: PctOp; start: number; end: number }
|
||||||
| { t: "percent"; start: number; end: number }
|
| { t: "percent"; start: number; end: number }
|
||||||
| { t: "func"; name: string; start: number; end: number }
|
| { t: "func"; name: string; start: number; end: number }
|
||||||
| { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart"; start: number; end: number }
|
| { t: "agg"; name: "sum" | "avg" | "prev"; start: number; end: number }
|
||||||
| { t: "scale"; mult: Decimal; start: number; end: number }
|
| { t: "scale"; mult: Decimal; start: number; end: number }
|
||||||
| { t: "repr"; repr: NumeralRepr; start: number; end: number }
|
| { t: "repr"; repr: NumeralRepr; start: number; end: number }
|
||||||
| { t: "date"; word: DateWord; start: number; end: number }
|
| { t: "date"; word: DateWord; start: number; end: number }
|
||||||
| { t: "datelit"; ms: number; start: number; end: number }
|
|
||||||
| { t: "xref"; sheet: string; key: string; start: number; end: number }
|
|
||||||
| { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
|
| { t: "const"; name: "pi" | "e" | "half" | "onehalf"; start: number; end: number }
|
||||||
| { t: "lparen"; start: number; end: number }
|
| { t: "lparen"; start: number; end: number }
|
||||||
| { t: "rparen"; start: number; end: number }
|
| { t: "rparen"; start: number; end: number }
|
||||||
| { t: "semicolon"; start: number; end: number }
|
| { t: "semicolon"; start: number; end: number }
|
||||||
| { t: "bang"; start: number; end: number }
|
| { t: "bang"; start: number; end: number }
|
||||||
| { t: "word"; raw: string; start: number; end: number }
|
| { t: "word"; raw: string; start: number; end: number }
|
||||||
| { t: "unknown"; start: number; end: number }
|
|
||||||
| { t: "junk"; raw: string; start: number; end: number };
|
| { t: "junk"; raw: string; start: number; end: number };
|
||||||
|
|
||||||
export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dmy"): Token[] {
|
const SYM_OPS: Record<string, Token["t"] | "plus" | "minus" | "mul" | "div" | "pow"> = {};
|
||||||
const lexes = lexLine(line, dateOrder);
|
|
||||||
|
export function tokenize(line: string, reg: Registry): Token[] {
|
||||||
|
void SYM_OPS;
|
||||||
|
const lexes = lexLine(line);
|
||||||
const lowers = lexes.map((l) => l.raw.toLowerCase());
|
const lowers = lexes.map((l) => l.raw.toLowerCase());
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
@ -45,18 +45,6 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lx.type === "date") {
|
|
||||||
tokens.push({ t: "datelit", ms: lx.dateMs!, ...span });
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lx.type === "xref") {
|
|
||||||
tokens.push({ t: "xref", sheet: lx.sheet!, key: lx.key!, ...span });
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = reg.match(lexes, lowers, i);
|
const m = reg.match(lexes, lowers, i);
|
||||||
if (m) {
|
if (m) {
|
||||||
const end = lexes[i + m.length - 1].end;
|
const end = lexes[i + m.length - 1].end;
|
||||||
|
|
@ -105,7 +93,6 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
|
||||||
case "=": tokens.push({ t: "assign", ...span }); break;
|
case "=": tokens.push({ t: "assign", ...span }); break;
|
||||||
case ";": tokens.push({ t: "semicolon", ...span }); break;
|
case ";": tokens.push({ t: "semicolon", ...span }); break;
|
||||||
case "!": tokens.push({ t: "bang", ...span }); break;
|
case "!": tokens.push({ t: "bang", ...span }); break;
|
||||||
case "?": tokens.push({ t: "unknown", ...span }); break;
|
|
||||||
default: tokens.push({ t: "junk", raw: lx.raw, ...span });
|
default: tokens.push({ t: "junk", raw: lx.raw, ...span });
|
||||||
}
|
}
|
||||||
i++;
|
i++;
|
||||||
|
|
@ -115,7 +102,7 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
|
||||||
tokens.push({ t: "word", raw: lx.raw, ...span });
|
tokens.push({ t: "word", raw: lx.raw, ...span });
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
return disambiguateMinAgg(disambiguateIn(tokens, reg, line), reg, line);
|
return disambiguateIn(tokens, reg, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -138,21 +125,3 @@ function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
|
||||||
return tk;
|
return tk;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "5 min": the "min" aggregate keyword (registered first, so it wins the
|
|
||||||
* phrase in the registry) collides with the minute unit's "min"/"мин"
|
|
||||||
* abbreviation. Right after a number it can only mean minutes — the
|
|
||||||
* standalone `min` aggregate line has no preceding number to collide with.
|
|
||||||
*/
|
|
||||||
function disambiguateMinAgg(tokens: Token[], reg: Registry, line: string): Token[] {
|
|
||||||
const minute = reg.unitsById.get("minute");
|
|
||||||
if (!minute) return tokens;
|
|
||||||
return tokens.map((tk, i) => {
|
|
||||||
if (tk.t !== "agg" || tk.name !== "min") return tk;
|
|
||||||
const raw = line.slice(tk.start, tk.end).toLowerCase();
|
|
||||||
if (raw !== "min" && raw !== "мин") return tk;
|
|
||||||
if (tokens[i - 1]?.t !== "num") return tk;
|
|
||||||
return { t: "unit", unit: minute, start: tk.start, end: tk.end } as Token;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { qty, pct, Decimal } from "./types";
|
|
||||||
|
|
||||||
describe("types", () => {
|
|
||||||
describe("qty", () => {
|
|
||||||
it("creates a quantity with default arguments", () => {
|
|
||||||
const result = qty(42);
|
|
||||||
expect(result.kind).toBe("quantity");
|
|
||||||
expect(result.value).toBeInstanceOf(Decimal);
|
|
||||||
expect(result.value.toNumber()).toBe(42);
|
|
||||||
expect(result.unit).toBeNull();
|
|
||||||
expect(result.repr).toBe("decimal");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a quantity with all arguments provided", () => {
|
|
||||||
const mockUnit = { id: "meter", dimension: "length", ratio: new Decimal(1), format: "m" } as any;
|
|
||||||
const result = qty("3.14", mockUnit, "hex");
|
|
||||||
expect(result.kind).toBe("quantity");
|
|
||||||
expect(result.value).toBeInstanceOf(Decimal);
|
|
||||||
expect(result.value.toString()).toBe("3.14");
|
|
||||||
expect(result.unit).toBe(mockUnit);
|
|
||||||
expect(result.repr).toBe("hex");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pct", () => {
|
|
||||||
it("creates a percent value from number", () => {
|
|
||||||
const result = pct(25);
|
|
||||||
expect(result.kind).toBe("percent");
|
|
||||||
expect(result.value).toBeInstanceOf(Decimal);
|
|
||||||
expect(result.value.toNumber()).toBe(25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a percent value from string", () => {
|
|
||||||
const result = pct("12.5");
|
|
||||||
expect(result.kind).toBe("percent");
|
|
||||||
expect(result.value).toBeInstanceOf(Decimal);
|
|
||||||
expect(result.value.toString()).toBe("12.5");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,11 +4,6 @@ Decimal.set({ precision: 40, toExpNeg: -9, toExpPos: 21 });
|
||||||
|
|
||||||
export { Decimal };
|
export { Decimal };
|
||||||
|
|
||||||
/** Shared here (not in evaluator.ts) so unitdata.ts can use it too without a cycle. */
|
|
||||||
export const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
|
|
||||||
export const DEC_ZERO = new Decimal(0);
|
|
||||||
export const DEC_ONE = new Decimal(1);
|
|
||||||
|
|
||||||
export type Dimension =
|
export type Dimension =
|
||||||
| "scalar"
|
| "scalar"
|
||||||
| "length"
|
| "length"
|
||||||
|
|
@ -29,12 +24,6 @@ export type Dimension =
|
||||||
|
|
||||||
export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
|
export type NumeralRepr = "decimal" | "hex" | "binary" | "octal" | "scientific" | "fraction" | "roman" | "plain";
|
||||||
|
|
||||||
/** day-first (31.12.2024) or month-first (12/31/2024) for ambiguous date literals */
|
|
||||||
export type DateOrder = "dmy" | "mdy";
|
|
||||||
|
|
||||||
/** "system" renders via the OS locale; the rest force a fixed layout */
|
|
||||||
export type DateFormat = "system" | "iso" | "dmy" | "mdy";
|
|
||||||
|
|
||||||
/** A resolved unit instance attached to a value. */
|
/** A resolved unit instance attached to a value. */
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
/** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
|
/** canonical id, e.g. "meter", "kilo:meter", "USD", "sq:foot" */
|
||||||
|
|
@ -73,13 +62,7 @@ export interface DateVal {
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartValue {
|
export type Value = Quantity | PercentVal | DateVal;
|
||||||
kind: "chart";
|
|
||||||
points: Decimal[];
|
|
||||||
unitLabel: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Value = Quantity | PercentVal | DateVal | ChartValue;
|
|
||||||
|
|
||||||
export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity {
|
export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity {
|
||||||
return { kind: "quantity", value: new Decimal(value), unit, repr };
|
return { kind: "quantity", value: new Decimal(value), unit, repr };
|
||||||
|
|
@ -90,7 +73,6 @@ export function pct(value: Decimal.Value): PercentVal {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EvalError extends Error {}
|
export class EvalError extends Error {}
|
||||||
export class XRefError extends EvalError {}
|
|
||||||
|
|
||||||
export interface EngineSettings {
|
export interface EngineSettings {
|
||||||
/** max decimal places shown */
|
/** max decimal places shown */
|
||||||
|
|
@ -99,13 +81,10 @@ export interface EngineSettings {
|
||||||
groupSeparator: string;
|
groupSeparator: string;
|
||||||
/** decimal separator shown in output */
|
/** decimal separator shown in output */
|
||||||
decimalSeparator: string;
|
decimalSeparator: string;
|
||||||
/** date display layout; also disambiguates 01/02/2024 on input */
|
|
||||||
dateFormat: DateFormat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings: EngineSettings = {
|
export const defaultSettings: EngineSettings = {
|
||||||
precision: 2,
|
precision: 2,
|
||||||
groupSeparator: ",",
|
groupSeparator: ",",
|
||||||
decimalSeparator: ".",
|
decimalSeparator: ".",
|
||||||
dateFormat: "system",
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Conversion ratios. Base units per dimension: length=meter, area=m²,
|
// Conversion ratios. Base units per dimension: length=meter, area=m²,
|
||||||
// volume=m³, weight=gram, time=second, angle=radian, data=bit,
|
// volume=m³, weight=gram, time=second, angle=radian, data=bit,
|
||||||
// temperature=kelvin (ratio+offset).
|
// temperature=kelvin (ratio+offset).
|
||||||
import { Decimal, Dimension, PI } from "./types";
|
import { Decimal, Dimension } from "./types";
|
||||||
|
|
||||||
export interface UnitData {
|
export interface UnitData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -79,7 +79,7 @@ export const UNIT_DATA: UnitData[] = [
|
||||||
|
|
||||||
// Angle (base: radian)
|
// Angle (base: radian)
|
||||||
{ id: "radian", category: "Angle", dimension: "angle", ratio: "1" },
|
{ id: "radian", category: "Angle", dimension: "angle", ratio: "1" },
|
||||||
{ id: "degree", category: "Angle", dimension: "angle", ratio: PI.div(180).toString() },
|
{ id: "degree", category: "Angle", dimension: "angle", ratio: String(Math.PI / 180) },
|
||||||
|
|
||||||
// Data (base: bit)
|
// Data (base: bit)
|
||||||
{ id: "bit", category: "Data", dimension: "data", ratio: "1", prefixes: "data" },
|
{ id: "bit", category: "Data", dimension: "data", ratio: "1", prefixes: "data" },
|
||||||
|
|
|
||||||
|
|
@ -169,13 +169,11 @@ export const EN: VocabLocale = {
|
||||||
"KES.format": "%@ KSh",
|
"KES.format": "%@ KSh",
|
||||||
"KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh",
|
"KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh",
|
||||||
"KGS.format": "%@ sом",
|
"KGS.format": "%@ sом",
|
||||||
// bare "Som"/"Soms" dropped — UZS (larger economy) owns it; say "Kyrgyzstani Som" for KGS
|
"KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,Soms,Som,sом",
|
||||||
"KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,sом",
|
|
||||||
"KHR.format": "%@ R.",
|
"KHR.format": "%@ R.",
|
||||||
"KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels",
|
"KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels",
|
||||||
"KMF.format": "%@ FC",
|
"KMF.format": "%@ FC",
|
||||||
// bare "FC" dropped — CDF (Congolese franc, registered first) owns it; say "Comorian Franc" for KMF
|
"KMF.variants": "Comorian Francs,Comorian Franc,FC",
|
||||||
"KMF.variants": "Comorian Francs,Comorian Franc",
|
|
||||||
"KPW.format": "₩ %@",
|
"KPW.format": "₩ %@",
|
||||||
"KPW.variants": "North Korean won",
|
"KPW.variants": "North Korean won",
|
||||||
"KRW.format": "₩ %@",
|
"KRW.format": "₩ %@",
|
||||||
|
|
@ -223,8 +221,7 @@ export const EN: VocabLocale = {
|
||||||
"MVR.format": "Rf %@",
|
"MVR.format": "Rf %@",
|
||||||
"MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf",
|
"MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf",
|
||||||
"MWK.format": "%@ MK",
|
"MWK.format": "%@ MK",
|
||||||
// bare "Kwacha" dropped — ZMW (larger economy) owns it; say "Malawian Kwacha" for MWK
|
"MWK.variants": "Malawian Kwacha,Kwacha",
|
||||||
"MWK.variants": "Malawian Kwacha",
|
|
||||||
"MXN.format": "$ %@",
|
"MXN.format": "$ %@",
|
||||||
"MXN.variants": "Mexican Peso,Mexican Pesos",
|
"MXN.variants": "Mexican Peso,Mexican Pesos",
|
||||||
"MYR.format": "RM %@",
|
"MYR.format": "RM %@",
|
||||||
|
|
@ -304,8 +301,7 @@ export const EN: VocabLocale = {
|
||||||
"TJS.format": "%@ TJS",
|
"TJS.format": "%@ TJS",
|
||||||
"TJS.variants": "Tajikistani Somoni,Somoni",
|
"TJS.variants": "Tajikistani Somoni,Somoni",
|
||||||
"TMT.format": "%@ TMT",
|
"TMT.format": "%@ TMT",
|
||||||
// bare "Manat" dropped — AZN (registered first) owns it; say "Turkmenistan Manat" for TMT
|
"TMT.variants": "Turkmenistan Manat,Manat",
|
||||||
"TMT.variants": "Turkmenistan Manat",
|
|
||||||
"TND.format": "%@ TD",
|
"TND.format": "%@ TD",
|
||||||
"TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD",
|
"TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD",
|
||||||
"TOP.format": "T$ %@",
|
"TOP.format": "T$ %@",
|
||||||
|
|
@ -414,7 +410,7 @@ export const EN: VocabLocale = {
|
||||||
"decimal.variants": "decimal,dec",
|
"decimal.variants": "decimal,dec",
|
||||||
"hex.variants": "hex",
|
"hex.variants": "hex",
|
||||||
"octal.variants": "octal",
|
"octal.variants": "octal",
|
||||||
"scientific.variants": "scientific, sci, exponent, exponential"
|
"scientific.variants": "scientific, sci, exp, exponent, exponential"
|
||||||
},
|
},
|
||||||
"Operations": {
|
"Operations": {
|
||||||
"assignment.variants": "equal,is",
|
"assignment.variants": "equal,is",
|
||||||
|
|
@ -431,15 +427,7 @@ export const EN: VocabLocale = {
|
||||||
"ln.variants": "natural log,natural logarithm",
|
"ln.variants": "natural log,natural logarithm",
|
||||||
"log.variants": "logarithm",
|
"log.variants": "logarithm",
|
||||||
"round.variants": "rounded",
|
"round.variants": "rounded",
|
||||||
"sum.variants": "sum,total",
|
"sum.variants": "sum,total"
|
||||||
"count.variants": "count,how many",
|
|
||||||
"min.variants": "min,minimum,smallest",
|
|
||||||
"max.variants": "max,maximum,largest",
|
|
||||||
"product.variants": "product",
|
|
||||||
"random.variants": "random,rand",
|
|
||||||
"chart.variants": "chart,spark,sparkline,graph",
|
|
||||||
"until.variants": "until,days until,day until",
|
|
||||||
"since.variants": "since,days since,day since"
|
|
||||||
},
|
},
|
||||||
"Percent": {
|
"Percent": {
|
||||||
"as_a_percent_of.variants": "as a % of,as a percent of",
|
"as_a_percent_of.variants": "as a % of,as a percent of",
|
||||||
|
|
@ -737,8 +725,7 @@ export const RU: VocabLocale = {
|
||||||
"MUR.variants": "Маврикийская рупия,Маврикийских рупий,Маврикийских рупиях",
|
"MUR.variants": "Маврикийская рупия,Маврикийских рупий,Маврикийских рупиях",
|
||||||
"MVR.format": "%@ Rf",
|
"MVR.format": "%@ Rf",
|
||||||
"MVR.variants": "Мальдивская руфия,Руфия,Руфий,Руфиях",
|
"MVR.variants": "Мальдивская руфия,Руфия,Руфий,Руфиях",
|
||||||
// bare "Квача"/"Квачах" dropped — ZMW (larger economy) owns them; the fuller phrases stay unambiguous
|
"MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах,Квача,Квачах",
|
||||||
"MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах",
|
|
||||||
"MXN.format": "%@ $",
|
"MXN.format": "%@ $",
|
||||||
"MXN.variants": "Мексиканских песо,Мексиканское песо",
|
"MXN.variants": "Мексиканских песо,Мексиканское песо",
|
||||||
"MYR.format": "%@ RM",
|
"MYR.format": "%@ RM",
|
||||||
|
|
@ -798,8 +785,7 @@ export const RU: VocabLocale = {
|
||||||
"THB.variants": "Тайский бат,Бат,Батов,Батах",
|
"THB.variants": "Тайский бат,Бат,Батов,Батах",
|
||||||
"TJS.format": "%@ смн.",
|
"TJS.format": "%@ смн.",
|
||||||
"TJS.variants": "Таджикский сомони,Сомони",
|
"TJS.variants": "Таджикский сомони,Сомони",
|
||||||
// bare "Манат" dropped — AZN owns it; the fuller phrases below stay unambiguous
|
"TMT.variants": "Туркменский манат,Новый туркменский манат,Манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
|
||||||
"TMT.variants": "Туркменский манат,Новый туркменский манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
|
|
||||||
"TND.variants": "Тунисские Динары,Тунисский Динар,Тунисских Динаров,Тунисских Динарах",
|
"TND.variants": "Тунисские Динары,Тунисский Динар,Тунисских Динаров,Тунисских Динарах",
|
||||||
"TOP.format": "%@ T$",
|
"TOP.format": "%@ T$",
|
||||||
"TOP.variants": "Тонганская паанга,Паанга,Паангах",
|
"TOP.variants": "Тонганская паанга,Паанга,Паангах",
|
||||||
|
|
@ -913,15 +899,7 @@ export const RU: VocabLocale = {
|
||||||
"ln.variants": "натуральный логарифм,нат. логарифм",
|
"ln.variants": "натуральный логарифм,нат. логарифм",
|
||||||
"log.variants": "логарифм",
|
"log.variants": "логарифм",
|
||||||
"round.variants": "округлить,округление",
|
"round.variants": "округлить,округление",
|
||||||
"sum.variants": "всего,сумма,итог,итого",
|
"sum.variants": "всего,сумма,итог,итого"
|
||||||
"count.variants": "количество,кол-во,счёт,число строк",
|
|
||||||
"min.variants": "минимум,мин,наименьшее",
|
|
||||||
"max.variants": "максимум,макс,наибольшее",
|
|
||||||
"product.variants": "произведение,произв",
|
|
||||||
"random.variants": "случайное,случайно,рандом",
|
|
||||||
"chart.variants": "график,спарклайн,диаграмма",
|
|
||||||
"until.variants": "осталось до,дней до,дней до даты",
|
|
||||||
"since.variants": "прошло с,дней с,дней прошло с"
|
|
||||||
},
|
},
|
||||||
"Percent": {
|
"Percent": {
|
||||||
"as_a_percent_of.variants": "как процент от,как процент",
|
"as_a_percent_of.variants": "как процент от,как процент",
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("./vocab-data", () => ({
|
|
||||||
EN: {
|
|
||||||
"TestCategory": {
|
|
||||||
"test_key.variants": "a, b, c ,a, ",
|
|
||||||
"test_key.format": "en_fmt",
|
|
||||||
"test_key.suffix1": "val1",
|
|
||||||
"test_key2.variants": "x,y",
|
|
||||||
"test_key2.suffix1": "val2",
|
|
||||||
"same.key": "duplicate",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
RU: {
|
|
||||||
"TestCategory": {
|
|
||||||
"test_key.variants": "d, b , e",
|
|
||||||
"test_key.format": "ru_fmt",
|
|
||||||
"test_key3.variants": "z",
|
|
||||||
"test_key3.suffix2": "val3",
|
|
||||||
"same.key": "duplicate",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { variants, entry, entryEn, entriesAll, idsOf, idsWithSuffix } from "./vocab";
|
|
||||||
|
|
||||||
describe("vocab", () => {
|
|
||||||
describe("variants", () => {
|
|
||||||
it("returns comma-split, trimmed, and deduped variants across locales", () => {
|
|
||||||
// EN has "a", "b", "c", "a", "" (empty string trimmed out)
|
|
||||||
// RU has "d", "b", "e"
|
|
||||||
const res = variants("TestCategory", "test_key.variants");
|
|
||||||
expect(res).toEqual(["a", "b", "c", "d", "e"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for unknown category or key", () => {
|
|
||||||
expect(variants("UnknownCategory", "test_key.variants")).toEqual([]);
|
|
||||||
expect(variants("TestCategory", "unknown_key.variants")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("entry", () => {
|
|
||||||
it("returns the first locale's value", () => {
|
|
||||||
expect(entry("TestCategory", "test_key.format")).toBe("en_fmt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for unknown category or key", () => {
|
|
||||||
expect(entry("UnknownCategory", "test_key.format")).toBeUndefined();
|
|
||||||
expect(entry("TestCategory", "unknown_key")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("entryEn", () => {
|
|
||||||
it("returns only the English value", () => {
|
|
||||||
expect(entryEn("TestCategory", "test_key.format")).toBe("en_fmt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined if only in RU", () => {
|
|
||||||
expect(entryEn("TestCategory", "test_key3.variants")).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("entriesAll", () => {
|
|
||||||
it("returns all unique values across locales", () => {
|
|
||||||
expect(entriesAll("TestCategory", "test_key.format")).toEqual(["en_fmt", "ru_fmt"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deduplicates values if they are the same in multiple locales", () => {
|
|
||||||
expect(entriesAll("TestCategory", "same.key")).toEqual(["duplicate"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for unknown category or key", () => {
|
|
||||||
expect(entriesAll("UnknownCategory", "test_key.format")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("idsOf", () => {
|
|
||||||
it("returns all base keys ending in .variants across locales", () => {
|
|
||||||
const res = idsOf("TestCategory");
|
|
||||||
expect(res.sort()).toEqual(["test_key", "test_key2", "test_key3"].sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for unknown category", () => {
|
|
||||||
expect(idsOf("UnknownCategory")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("idsWithSuffix", () => {
|
|
||||||
it("returns all keys ending with the given suffix across locales", () => {
|
|
||||||
const res1 = idsWithSuffix("TestCategory", ".suffix1");
|
|
||||||
expect(res1.sort()).toEqual(["test_key", "test_key2"].sort());
|
|
||||||
|
|
||||||
const res2 = idsWithSuffix("TestCategory", ".suffix2");
|
|
||||||
expect(res2).toEqual(["test_key3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for unknown category or suffix", () => {
|
|
||||||
expect(idsWithSuffix("UnknownCategory", ".suffix1")).toEqual([]);
|
|
||||||
expect(idsWithSuffix("TestCategory", ".unknown_suffix")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { makeApi, runExtensions } from "./extensions";
|
|
||||||
import { SumEngine } from "./engine";
|
|
||||||
|
|
||||||
describe("extensions", () => {
|
|
||||||
let mockEngine: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockEngine = {
|
|
||||||
setVariable: vi.fn(),
|
|
||||||
addUnit: vi.fn(),
|
|
||||||
addFunction: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("makeApi", () => {
|
|
||||||
it("delegates setVariable to engine", () => {
|
|
||||||
const api = makeApi(mockEngine as SumEngine);
|
|
||||||
api.setVariable("myVar", 42);
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("myVar", 42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates addUnit to engine", () => {
|
|
||||||
const api = makeApi(mockEngine as SumEngine);
|
|
||||||
const spec = { id: "testUnit", phrases: "tests", baseUnitId: "USD", ratio: 1 };
|
|
||||||
api.addUnit(spec);
|
|
||||||
expect(mockEngine.addUnit).toHaveBeenCalledWith(spec);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delegates addFunction to engine", () => {
|
|
||||||
const api = makeApi(mockEngine as SumEngine);
|
|
||||||
const spec = { id: "testFunc", phrases: "test" };
|
|
||||||
const fn = () => 42;
|
|
||||||
api.addFunction(spec, fn);
|
|
||||||
expect(mockEngine.addFunction).toHaveBeenCalledWith(spec, fn);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("runExtensions", () => {
|
|
||||||
let consoleErrorSpy: any;
|
|
||||||
let consoleWarnSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
|
|
||||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
consoleWarnSpy.mockRestore();
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doUnmock("quickjs-emscripten-core");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs scripts successfully using numi api", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
|
||||||
{ name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
|
||||||
expect(mockEngine.addUnit).toHaveBeenCalledWith({
|
|
||||||
id: "unit1",
|
|
||||||
phrases: "u",
|
|
||||||
baseUnitId: "USD",
|
|
||||||
ratio: 1,
|
|
||||||
});
|
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("swallows errors and logs to console.error", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]);
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error));
|
|
||||||
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("continues running other scripts if one fails", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
|
||||||
{ name: "script2", code: "throw new Error('boom');" },
|
|
||||||
{ name: "script3", code: "numi.setVariable('var3', 30);" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes unit-carrying values through setVariable", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{ name: "s", code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { double: 1200, unitId: "USD" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed api arguments as a script error", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [{ name: "bad", code: "numi.setVariable('x', 'nope');" }]);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).not.toHaveBeenCalled();
|
|
||||||
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("numi.setVariable");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("registers a function callable with one array of values", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{
|
|
||||||
name: "hyp",
|
|
||||||
code: "numi.addFunction({ id: 'hyp', phrases: 'hyp' }, function (values) { return { double: Math.hypot(values[0].double, values[1].double) }; });",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mockEngine.addFunction).toHaveBeenCalledWith({ id: "hyp", phrases: "hyp" }, expect.any(Function));
|
|
||||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
|
||||||
expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 });
|
|
||||||
expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marshals unitId into the sandbox and back out", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{
|
|
||||||
name: "echo",
|
|
||||||
code: "numi.addFunction({ id: 'echo', phrases: 'echo' }, function (v) { return { double: v[0].double * 2, unitId: v[0].unitId }; });",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
|
||||||
expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ double: 10, unitId: "meter" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has no host globals inside the sandbox", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{ name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("interrupts an infinite loop at load time", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{ name: "spin", code: "while (true) {}" },
|
|
||||||
{ name: "after", code: "numi.setVariable('ok', 1);" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:");
|
|
||||||
// the runtime survives the interrupt and keeps loading scripts
|
|
||||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("interrupts a hung function on every call, not just the first", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{
|
|
||||||
name: "spin",
|
|
||||||
code: "numi.addFunction({ id: 'spin', phrases: 'spin' }, function () { while (true) {} });",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "good",
|
|
||||||
code: "numi.addFunction({ id: 'good', phrases: 'good' }, function (v) { return v[0].double + 1; });",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const spin = mockEngine.addFunction.mock.calls[0][1];
|
|
||||||
const good = mockEngine.addFunction.mock.calls[1][1];
|
|
||||||
|
|
||||||
expect(() => spin([])).toThrow(/spin/);
|
|
||||||
// deadline is re-armed per call: a second call still times out rather than
|
|
||||||
// failing instantly, and an unrelated function still works afterwards
|
|
||||||
expect(() => spin([])).toThrow(/spin/);
|
|
||||||
expect(good([{ double: 1 }])).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces a bad return value as a function error", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, [
|
|
||||||
{ name: "bad", code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
|
||||||
expect(() => fn([])).toThrow(/bad/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips extensions when the sandbox fails to load", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("quickjs-emscripten-core", () => ({
|
|
||||||
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
|
|
||||||
}));
|
|
||||||
const { runExtensions: run } = await import("./extensions");
|
|
||||||
const { logger } = await import("./logger");
|
|
||||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
|
|
||||||
|
|
||||||
expect(mockEngine.setVariable).not.toHaveBeenCalled();
|
|
||||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
||||||
"extensions disabled: QuickJS sandbox unavailable:",
|
|
||||||
expect.any(Error),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not load the sandbox when there are no scripts", async () => {
|
|
||||||
await runExtensions(mockEngine as SumEngine, []);
|
|
||||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,21 +1,11 @@
|
||||||
import { logger } from "./logger";
|
// JS extensions runtime: numi.setVariable,
|
||||||
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
|
||||||
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
|
||||||
import {
|
|
||||||
newQuickJSWASMModuleFromVariant,
|
|
||||||
QuickJSContext,
|
|
||||||
QuickJSHandle,
|
|
||||||
} from "quickjs-emscripten-core";
|
|
||||||
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
|
|
||||||
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
||||||
|
|
||||||
interface ExtensionApi {
|
export interface ExtensionApi {
|
||||||
setVariable(name: string, value: number | ExtensionValue): void;
|
setVariable(name: string, value: number | ExtensionValue): void;
|
||||||
addUnit(spec: ExtensionUnitSpec): void;
|
addUnit(spec: ExtensionUnitSpec): void;
|
||||||
addFunction(
|
addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void;
|
||||||
spec: { id: string; phrases: string },
|
|
||||||
fn: (values: ExtensionValue[]) => ExtensionValue | number,
|
|
||||||
): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeApi(engine: SumEngine): ExtensionApi {
|
export function makeApi(engine: SumEngine): ExtensionApi {
|
||||||
|
|
@ -26,209 +16,14 @@ export function makeApi(engine: SumEngine): ExtensionApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 32 MB is orders of magnitude more than a sheet helper needs, and small enough
|
export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
|
||||||
// that a runaway allocation fails inside the sandbox instead of the host heap.
|
const numi = makeApi(engine);
|
||||||
const MEMORY_LIMIT = 32 * 1024 * 1024;
|
|
||||||
// 512 KB — unbounded recursion hits this and throws in the sandbox, never on the JS stack.
|
|
||||||
const STACK_SIZE = 512 * 1024;
|
|
||||||
// One-off cost at startup, so it can be generous.
|
|
||||||
const LOAD_TIMEOUT_MS = 1000;
|
|
||||||
// Registered functions are called synchronously from evaluateDocument, i.e. on
|
|
||||||
// every keystroke for every line that uses them — this budget must stay small.
|
|
||||||
const CALL_TIMEOUT_MS = 50;
|
|
||||||
|
|
||||||
const now = () =>
|
|
||||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
||||||
|
|
||||||
function toExtValue(v: unknown, what: string): ExtensionValue | number {
|
|
||||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
||||||
if (v && typeof v === "object") {
|
|
||||||
const o = v as { double?: unknown; unitId?: unknown };
|
|
||||||
if (typeof o.double === "number" && Number.isFinite(o.double)) {
|
|
||||||
return typeof o.unitId === "string"
|
|
||||||
? { double: o.double, unitId: o.unitId }
|
|
||||||
: { double: o.double };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`${what}: expected a number or { double, unitId? }`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function message(e: unknown): string {
|
|
||||||
if (e instanceof Error) return e.message;
|
|
||||||
if (
|
|
||||||
e &&
|
|
||||||
typeof e === "object" &&
|
|
||||||
typeof (e as { message?: unknown }).message === "string"
|
|
||||||
) {
|
|
||||||
return (e as { message: string }).message;
|
|
||||||
}
|
|
||||||
return String(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a
|
|
||||||
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure.
|
|
||||||
// Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app.
|
|
||||||
export async function runExtensions(
|
|
||||||
engine: SumEngine,
|
|
||||||
scripts: { name: string; code: string }[],
|
|
||||||
): Promise<void> {
|
|
||||||
if (scripts.length === 0) return;
|
|
||||||
try {
|
|
||||||
await load(engine, scripts);
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("extensions disabled: QuickJS sandbox unavailable:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunWithTimeout = <T>(timeoutMs: number, fn: () => T) => T;
|
|
||||||
|
|
||||||
function callGuest(
|
|
||||||
vm: QuickJSContext,
|
|
||||||
id: string,
|
|
||||||
fn: QuickJSHandle,
|
|
||||||
values: ExtensionValue[],
|
|
||||||
runWithTimeout: RunWithTimeout,
|
|
||||||
): ExtensionValue | number {
|
|
||||||
// extension-sample.js convention: the callback takes one array of values.
|
|
||||||
const arr = vm.newArray();
|
|
||||||
try {
|
|
||||||
values.forEach((v, i) => {
|
|
||||||
const o = vm.newObject();
|
|
||||||
const d = vm.newNumber(v.double);
|
|
||||||
vm.setProp(o, "double", d);
|
|
||||||
d.dispose();
|
|
||||||
if (v.unitId) {
|
|
||||||
const u = vm.newString(v.unitId);
|
|
||||||
vm.setProp(o, "unitId", u);
|
|
||||||
u.dispose();
|
|
||||||
}
|
|
||||||
vm.setProp(arr, i, o);
|
|
||||||
o.dispose();
|
|
||||||
});
|
|
||||||
const res = runWithTimeout(CALL_TIMEOUT_MS, () =>
|
|
||||||
vm.callFunction(fn, vm.undefined, [arr]),
|
|
||||||
);
|
|
||||||
if (res.error) {
|
|
||||||
const err = vm.dump(res.error);
|
|
||||||
res.error.dispose();
|
|
||||||
throw new Error(`${id}: ${message(err)}`);
|
|
||||||
}
|
|
||||||
const out = vm.dump(res.value);
|
|
||||||
res.value.dispose();
|
|
||||||
return toExtValue(out, id);
|
|
||||||
} finally {
|
|
||||||
arr.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function exposeApi(
|
|
||||||
vm: QuickJSContext,
|
|
||||||
api: ExtensionApi,
|
|
||||||
runWithTimeout: RunWithTimeout,
|
|
||||||
) {
|
|
||||||
const numi = vm.newObject();
|
|
||||||
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
|
|
||||||
const fn = vm.newFunction(name, (...args) => {
|
|
||||||
try {
|
|
||||||
impl(...args);
|
|
||||||
} catch (e) {
|
|
||||||
return { error: vm.newError(`numi.${name}: ${message(e)}`) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
vm.setProp(numi, name, fn);
|
|
||||||
fn.dispose();
|
|
||||||
};
|
|
||||||
|
|
||||||
define("setVariable", (nameHandle, valueHandle) => {
|
|
||||||
const name = vm.dump(nameHandle);
|
|
||||||
if (typeof name !== "string" || !name)
|
|
||||||
throw new Error("expects a variable name");
|
|
||||||
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
|
|
||||||
});
|
|
||||||
|
|
||||||
define("addUnit", (specHandle) => {
|
|
||||||
const spec = vm.dump(specHandle);
|
|
||||||
if (
|
|
||||||
!spec ||
|
|
||||||
typeof spec.id !== "string" ||
|
|
||||||
typeof spec.baseUnitId !== "string" ||
|
|
||||||
typeof spec.ratio !== "number"
|
|
||||||
) {
|
|
||||||
throw new Error("expects { id, phrases, baseUnitId, ratio }");
|
|
||||||
}
|
|
||||||
api.addUnit({
|
|
||||||
id: spec.id,
|
|
||||||
phrases: typeof spec.phrases === "string" ? spec.phrases : spec.id,
|
|
||||||
baseUnitId: spec.baseUnitId,
|
|
||||||
...(typeof spec.format === "string" ? { format: spec.format } : {}),
|
|
||||||
ratio: spec.ratio,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
define("addFunction", (specHandle, fnHandle) => {
|
|
||||||
const spec = vm.dump(specHandle);
|
|
||||||
if (!spec || typeof spec.id !== "string")
|
|
||||||
throw new Error("expects { id, phrases }");
|
|
||||||
if (!fnHandle || vm.typeof(fnHandle) !== "function")
|
|
||||||
throw new Error("expects a function");
|
|
||||||
// outlives this callback, so it needs its own reference; the context is never disposed
|
|
||||||
const guestFn = fnHandle.dup();
|
|
||||||
const id = spec.id;
|
|
||||||
api.addFunction(
|
|
||||||
{ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id },
|
|
||||||
(values) => callGuest(vm, id, guestFn, values, runWithTimeout),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
vm.setProp(vm.global, "numi", numi);
|
|
||||||
numi.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateScripts(
|
|
||||||
vm: QuickJSContext,
|
|
||||||
scripts: { name: string; code: string }[],
|
|
||||||
runWithTimeout: RunWithTimeout,
|
|
||||||
) {
|
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
try {
|
try {
|
||||||
const res = runWithTimeout(LOAD_TIMEOUT_MS, () =>
|
const fn = new Function("numi", s.code);
|
||||||
vm.evalCode(s.code, s.name),
|
fn(numi);
|
||||||
);
|
|
||||||
if (res.error) {
|
|
||||||
const err = vm.dump(res.error);
|
|
||||||
res.error.dispose();
|
|
||||||
throw new Error(message(err));
|
|
||||||
}
|
|
||||||
res.value.dispose();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`extension ${s.name} failed:`, e);
|
console.error(`extension ${s.name} failed:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(
|
|
||||||
engine: SumEngine,
|
|
||||||
scripts: { name: string; code: string }[],
|
|
||||||
): Promise<void> {
|
|
||||||
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
|
|
||||||
const vm: QuickJSContext = mod.newContext();
|
|
||||||
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
|
|
||||||
vm.runtime.setMaxStackSize(STACK_SIZE);
|
|
||||||
|
|
||||||
// Armed before every entry into the sandbox and disarmed after, so a hung script
|
|
||||||
// at load time and a hung function call on keystroke N are both bounded.
|
|
||||||
let deadline = Infinity;
|
|
||||||
vm.runtime.setInterruptHandler(() => now() > deadline);
|
|
||||||
|
|
||||||
const runWithTimeout: RunWithTimeout = (timeoutMs, fn) => {
|
|
||||||
deadline = now() + timeoutMs;
|
|
||||||
try {
|
|
||||||
return fn();
|
|
||||||
} finally {
|
|
||||||
deadline = Infinity;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exposeApi(vm, makeApi(engine), runWithTimeout);
|
|
||||||
evaluateScripts(vm, scripts, runWithTimeout);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
||||||
import { setLang, t, detectLang } from "./i18n";
|
|
||||||
|
|
||||||
describe("i18n", () => {
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
|
|
||||||
const mockElements = [
|
|
||||||
{ dataset: { i18n: "settings" }, textContent: "" },
|
|
||||||
{ dataset: { i18n: "theme" }, textContent: "" }
|
|
||||||
];
|
|
||||||
|
|
||||||
vi.stubGlobal("document", {
|
|
||||||
documentElement: { lang: "" },
|
|
||||||
querySelectorAll: vi.fn().mockReturnValue(mockElements)
|
|
||||||
});
|
|
||||||
vi.stubGlobal("navigator", { language: "en-US" });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets language and updates document lang", () => {
|
|
||||||
setLang("ru");
|
|
||||||
expect(t("settings")).toBe("Настройки");
|
|
||||||
expect(document.documentElement.lang).toBe("ru");
|
|
||||||
|
|
||||||
// Check if querySelectorAll was called and elements were updated
|
|
||||||
const elements = document.querySelectorAll("[data-i18n]");
|
|
||||||
expect(elements[0].textContent).toBe("Настройки");
|
|
||||||
expect(elements[1].textContent).toBe("Тема");
|
|
||||||
|
|
||||||
setLang("en");
|
|
||||||
expect(t("settings")).toBe("Settings");
|
|
||||||
expect(document.documentElement.lang).toBe("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to en for unknown languages", () => {
|
|
||||||
setLang("fr");
|
|
||||||
expect(t("settings")).toBe("Settings");
|
|
||||||
expect(document.documentElement.lang).toBe("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to en dictionary for missing keys in ru", () => {
|
|
||||||
// If a key exists in en but not ru, it should fallback to en
|
|
||||||
// we don't have one right now, but we can test missing keys in both
|
|
||||||
expect(t("unknown_key")).toBe("unknown_key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detects language from navigator", () => {
|
|
||||||
vi.stubGlobal("navigator", { language: "ru-RU" });
|
|
||||||
expect(detectLang()).toBe("ru");
|
|
||||||
|
|
||||||
vi.stubGlobal("navigator", { language: "en-US" });
|
|
||||||
expect(detectLang()).toBe("en");
|
|
||||||
|
|
||||||
vi.stubGlobal("navigator", { language: undefined });
|
|
||||||
expect(detectLang()).toBe("en");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
42
src/i18n.ts
42
src/i18n.ts
|
|
@ -27,7 +27,6 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
resultswidth: "Results column",
|
resultswidth: "Results column",
|
||||||
selection: "Selection",
|
selection: "Selection",
|
||||||
rates: "Rates",
|
rates: "Rates",
|
||||||
market: "Market",
|
|
||||||
justNow: "just now",
|
justNow: "just now",
|
||||||
minAgo: "{}m ago",
|
minAgo: "{}m ago",
|
||||||
hourAgo: "{}h ago",
|
hourAgo: "{}h ago",
|
||||||
|
|
@ -56,26 +55,6 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
updateFailed: "Update failed",
|
updateFailed: "Update failed",
|
||||||
searchPlaceholder: "Search all sheets…",
|
searchPlaceholder: "Search all sheets…",
|
||||||
searchEmpty: "No matches",
|
searchEmpty: "No matches",
|
||||||
closeSheet: "Close this sheet?",
|
|
||||||
close: "Close",
|
|
||||||
cancel: "Cancel",
|
|
||||||
pin: "Pin",
|
|
||||||
unpin: "Unpin",
|
|
||||||
marketUpdated: "Market data updated",
|
|
||||||
exportImage: "Copy as image",
|
|
||||||
imageCopied: "Image copied",
|
|
||||||
imageFailed: "Could not copy image",
|
|
||||||
checkUpdate: "Check for updates",
|
|
||||||
upToDate: "You're on the latest version",
|
|
||||||
updateCheckFailed: "Could not check for updates",
|
|
||||||
autoUpdate: "Automatically check for updates",
|
|
||||||
dateformat: "Date format",
|
|
||||||
sysDefault: "System",
|
|
||||||
tabGeneral: "General",
|
|
||||||
tabFormat: "Format",
|
|
||||||
tabData: "Data",
|
|
||||||
tabUpdates: "Updates",
|
|
||||||
version: "Version",
|
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
|
|
@ -103,7 +82,6 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
resultswidth: "Колонка результатов",
|
resultswidth: "Колонка результатов",
|
||||||
selection: "Выделение",
|
selection: "Выделение",
|
||||||
rates: "Курсы",
|
rates: "Курсы",
|
||||||
market: "Рынок",
|
|
||||||
justNow: "только что",
|
justNow: "только что",
|
||||||
minAgo: "{} мин назад",
|
minAgo: "{} мин назад",
|
||||||
hourAgo: "{} ч назад",
|
hourAgo: "{} ч назад",
|
||||||
|
|
@ -132,26 +110,6 @@ const dict: Record<string, Record<string, string>> = {
|
||||||
updateFailed: "Не удалось обновить",
|
updateFailed: "Не удалось обновить",
|
||||||
searchPlaceholder: "Поиск по всем листам…",
|
searchPlaceholder: "Поиск по всем листам…",
|
||||||
searchEmpty: "Ничего не найдено",
|
searchEmpty: "Ничего не найдено",
|
||||||
closeSheet: "Закрыть этот лист?",
|
|
||||||
close: "Закрыть",
|
|
||||||
cancel: "Отмена",
|
|
||||||
pin: "Закрепить",
|
|
||||||
unpin: "Открепить",
|
|
||||||
marketUpdated: "Рыночные данные обновлены",
|
|
||||||
exportImage: "Скопировать картинкой",
|
|
||||||
imageCopied: "Картинка скопирована",
|
|
||||||
imageFailed: "Не удалось скопировать картинку",
|
|
||||||
checkUpdate: "Проверить обновления",
|
|
||||||
upToDate: "У вас последняя версия",
|
|
||||||
updateCheckFailed: "Не удалось проверить обновления",
|
|
||||||
autoUpdate: "Автоматически проверять обновления",
|
|
||||||
dateformat: "Формат даты",
|
|
||||||
sysDefault: "Системный",
|
|
||||||
tabGeneral: "Общие",
|
|
||||||
tabFormat: "Формат",
|
|
||||||
tabData: "Данные",
|
|
||||||
tabUpdates: "Обновления",
|
|
||||||
version: "Версия",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { logger } from './logger';
|
|
||||||
|
|
||||||
describe('logger', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
||||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call console.info with correct arguments', () => {
|
|
||||||
logger.info('test info', { key: 'value' });
|
|
||||||
expect(console.info).toHaveBeenCalledWith('test info', { key: 'value' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call console.warn with correct arguments', () => {
|
|
||||||
logger.warn('test warn', 42);
|
|
||||||
expect(console.warn).toHaveBeenCalledWith('test warn', 42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call console.error with correct arguments', () => {
|
|
||||||
const error = new Error('test error');
|
|
||||||
logger.error('test error message', error);
|
|
||||||
expect(console.error).toHaveBeenCalledWith('test error message', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export const logger = {
|
|
||||||
info: (...args: any[]) => console.info(...args),
|
|
||||||
warn: (...args: any[]) => console.warn(...args),
|
|
||||||
error: (...args: any[]) => console.error(...args),
|
|
||||||
};
|
|
||||||
127
src/main.test.ts
127
src/main.test.ts
|
|
@ -1,127 +0,0 @@
|
||||||
import { test, vi, expect } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("./ui/editor", () => ({
|
|
||||||
SumEditor: vi.fn().mockImplementation(() => ({
|
|
||||||
goToLine: vi.fn(),
|
|
||||||
getSheetWithResults: vi.fn(() => "test-results"),
|
|
||||||
getText: vi.fn(() => "test"),
|
|
||||||
focus: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
|
||||||
invoke: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/window", () => ({
|
|
||||||
getCurrentWindow: vi.fn(() => ({
|
|
||||||
minimize: vi.fn(),
|
|
||||||
hide: vi.fn(),
|
|
||||||
show: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./storage", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal();
|
|
||||||
return {
|
|
||||||
...actual as object,
|
|
||||||
isTauri: vi.fn(() => false),
|
|
||||||
loadSettings: vi.fn(() => ({ language: "en", autostart: false, alwaysOnTop: false, autoUpdateEnabled: false, sidebarVisible: false, hotkey: "CmdOrCtrl+Space" })),
|
|
||||||
loadAppData: vi.fn(() => ({ docs: [{id: "1", title: "Test"}], contents: {}, activeId: "1" })),
|
|
||||||
saveSettings: vi.fn(),
|
|
||||||
saveAppData: vi.fn(),
|
|
||||||
getLaunchFile: vi.fn(() => null),
|
|
||||||
flushAppData: vi.fn(),
|
|
||||||
onAppQuit: vi.fn((_cb: any) => {}),
|
|
||||||
onOpenFile: vi.fn(),
|
|
||||||
onFileDrop: vi.fn(),
|
|
||||||
registerHotkey: vi.fn(() => Promise.resolve(true)),
|
|
||||||
applyAutostart: vi.fn(),
|
|
||||||
applyAlwaysOnTop: vi.fn(),
|
|
||||||
fetchRates: vi.fn(),
|
|
||||||
fetchMarketData: vi.fn(),
|
|
||||||
loadExtensionScripts: vi.fn(() => Promise.resolve([])),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./ui/search", () => ({
|
|
||||||
initSearch: vi.fn(() => ({ open: vi.fn() }))
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./updater", () => ({
|
|
||||||
checkForUpdate: vi.fn(() => Promise.resolve(null))
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./extensions", () => ({
|
|
||||||
runExtensions: vi.fn(() => Promise.resolve())
|
|
||||||
}));
|
|
||||||
|
|
||||||
test("boot application", async () => {
|
|
||||||
const createMockElement = () => ({
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
classList: { add: vi.fn(), remove: vi.fn(), toggle: vi.fn(), contains: vi.fn() },
|
|
||||||
style: { setProperty: vi.fn() },
|
|
||||||
dataset: {},
|
|
||||||
value: "",
|
|
||||||
appendChild: vi.fn(),
|
|
||||||
setAttribute: vi.fn(),
|
|
||||||
getBoundingClientRect: vi.fn(() => ({ right: 100, width: 100 })),
|
|
||||||
closest: vi.fn(),
|
|
||||||
blur: vi.fn(),
|
|
||||||
replaceChildren: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mock$ = vi.fn(createMockElement);
|
|
||||||
|
|
||||||
vi.stubGlobal("document", {
|
|
||||||
querySelector: mock$,
|
|
||||||
querySelectorAll: vi.fn(() => []), // For i18n
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
body: createMockElement(),
|
|
||||||
documentElement: createMockElement(),
|
|
||||||
createElement: vi.fn(createMockElement),
|
|
||||||
createElementNS: vi.fn(createMockElement),
|
|
||||||
createDocumentFragment: vi.fn(createMockElement),
|
|
||||||
head: createMockElement(),
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("window", {
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
performance: { now: () => 0 },
|
|
||||||
print: vi.fn(),
|
|
||||||
getSelection: vi.fn(() => null),
|
|
||||||
setInterval: vi.fn(),
|
|
||||||
setTimeout: vi.fn().mockImplementation((cb: Function) => cb()),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
CustomEvent: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("matchMedia", vi.fn(() => ({
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
matches: false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
vi.stubGlobal("navigator", {
|
|
||||||
clipboard: { writeText: vi.fn() },
|
|
||||||
language: "en",
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal("localStorage", {
|
|
||||||
getItem: vi.fn(() => null),
|
|
||||||
setItem: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import main and wait for boot to complete
|
|
||||||
await import("./main");
|
|
||||||
await new Promise(r => setTimeout(r, 0));
|
|
||||||
|
|
||||||
// As mock is hoisted, we can import actual mocked funcs
|
|
||||||
const { loadSettings, loadAppData } = await import("./storage");
|
|
||||||
expect(loadSettings).toHaveBeenCalled();
|
|
||||||
expect(loadAppData).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
924
src/main.ts
924
src/main.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,419 +0,0 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
|
|
||||||
vi.mock('@tauri-apps/api/core', () => ({
|
|
||||||
invoke: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('storage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
vi.stubGlobal('window', {});
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: vi.fn(),
|
|
||||||
setItem: vi.fn(),
|
|
||||||
});
|
|
||||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isTauri', () => {
|
|
||||||
it('returns false when not in Tauri', () => {
|
|
||||||
expect(isTauri()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true when in Tauri', () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
expect(isTauri()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadSettings', () => {
|
|
||||||
it('loads from localStorage when not in Tauri', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ precision: 5 }));
|
|
||||||
const settings = await loadSettings();
|
|
||||||
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.settings');
|
|
||||||
expect(settings.precision).toBe(5);
|
|
||||||
expect(settings.theme).toBe(defaultSettingsData.theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads via invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 }));
|
|
||||||
const settings = await loadSettings();
|
|
||||||
expect(invoke).toHaveBeenCalledWith('load_file', { name: 'settings.json' });
|
|
||||||
expect(settings.precision).toBe(8);
|
|
||||||
expect(settings.theme).toBe(defaultSettingsData.theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
|
||||||
const settings = await loadSettings();
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaultSettingsData if invoke throws', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
||||||
const settings = await loadSettings();
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaultSettingsData if localStorage returns null', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
|
||||||
const settings = await loadSettings();
|
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaultSettingsData if JSON is invalid', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
|
||||||
const settings = await loadSettings();
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
|
||||||
expect(settings).toEqual(defaultSettingsData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('saveSettings', () => {
|
|
||||||
it('saves to localStorage when not in Tauri', async () => {
|
|
||||||
const settings = { ...defaultSettingsData, precision: 10 };
|
|
||||||
await saveSettings(settings);
|
|
||||||
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.settings', JSON.stringify(settings, null, 2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saves via invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
const settings = { ...defaultSettingsData, precision: 12 };
|
|
||||||
await saveSettings(settings);
|
|
||||||
expect(invoke).toHaveBeenCalledWith('save_file', { name: 'settings.json', contents: JSON.stringify(settings, null, 2) });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadAppData', () => {
|
|
||||||
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
|
||||||
|
|
||||||
it('loads from localStorage when not in Tauri', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockAppData));
|
|
||||||
const data = await loadAppData('');
|
|
||||||
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.documents');
|
|
||||||
expect(data).toEqual(mockAppData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads via invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
|
||||||
const data = await loadAppData('');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: null });
|
|
||||||
expect(data).toEqual(mockAppData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads via invoke when in Tauri with specific dataDir', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
|
||||||
const data = await loadAppData('custom_dir');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: 'custom_dir' });
|
|
||||||
expect(data).toEqual(mockAppData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if localStorage throws', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
|
||||||
const data = await loadAppData('');
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
|
||||||
expect(data).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if invoke throws', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
||||||
const data = await loadAppData('');
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
|
||||||
expect(data).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if localStorage returns null', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
|
||||||
const data = await loadAppData('');
|
|
||||||
expect(data).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if JSON is invalid', async () => {
|
|
||||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
|
||||||
const data = await loadAppData('');
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
|
||||||
expect(data).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('saveAppData & flushAppData', () => {
|
|
||||||
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('debounces saves and flushes correctly when not in Tauri', async () => {
|
|
||||||
setDataDir('');
|
|
||||||
saveAppData(mockAppData);
|
|
||||||
|
|
||||||
// Should not save immediately
|
|
||||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Fast forward past debounce time
|
|
||||||
vi.advanceTimersByTime(400);
|
|
||||||
|
|
||||||
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('debounces saves and flushes correctly when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
|
|
||||||
setDataDir('custom_dir');
|
|
||||||
saveAppData(mockAppData);
|
|
||||||
|
|
||||||
// Should not save immediately
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Fast forward past debounce time
|
|
||||||
vi.runAllTimers();
|
|
||||||
|
|
||||||
// storage.ts's invoke wrapper does a dynamic import() before calling
|
|
||||||
// invoke(), which resolves over real event-loop ticks (module
|
|
||||||
// transform/IO) even with fake timers active — poll with real timers
|
|
||||||
// until it settles instead of guessing a fixed number of ticks.
|
|
||||||
vi.useRealTimers();
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(invoke).toHaveBeenCalledWith('save_documents', { dir: 'custom_dir', contents: JSON.stringify(mockAppData) });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flushAppData clears timer and saves immediately', async () => {
|
|
||||||
setDataDir('');
|
|
||||||
saveAppData(mockAppData);
|
|
||||||
|
|
||||||
await flushAppData();
|
|
||||||
|
|
||||||
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
|
|
||||||
|
|
||||||
// Advancing timers should not trigger another save
|
|
||||||
vi.mocked(localStorage.setItem).mockClear();
|
|
||||||
vi.advanceTimersByTime(400);
|
|
||||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flushAppData does nothing if no pending data', async () => {
|
|
||||||
await flushAppData();
|
|
||||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles localStorage throw gracefully in flushAppData', async () => {
|
|
||||||
setDataDir('');
|
|
||||||
saveAppData(mockAppData);
|
|
||||||
vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
|
||||||
|
|
||||||
await flushAppData();
|
|
||||||
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles invoke throw gracefully in flushAppData', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
setDataDir('');
|
|
||||||
saveAppData(mockAppData);
|
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
||||||
|
|
||||||
await flushAppData();
|
|
||||||
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('backup and utility functions', () => {
|
|
||||||
describe('runBackups', () => {
|
|
||||||
it('does nothing when not in Tauri', async () => {
|
|
||||||
await runBackups('dir', 10);
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
await runBackups('dir', 10);
|
|
||||||
expect(invoke).toHaveBeenCalledWith('run_backups', { dir: 'dir', retentionDays: 10 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles invoke throw gracefully', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
||||||
await runBackups('dir', 10);
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('backupDeletedSheet', () => {
|
|
||||||
it('does nothing when not in Tauri', async () => {
|
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing if contents are empty', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
await backupDeletedSheet('dir', 'title', ' ');
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls invoke when in Tauri with valid contents', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('backup_deleted_sheet', { dir: 'dir', title: 'title', contents: 'contents' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles invoke throw gracefully', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
||||||
await backupDeletedSheet('dir', 'title', 'contents');
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('openBackupsFolder', () => {
|
|
||||||
it('does nothing when not in Tauri', async () => {
|
|
||||||
await openBackupsFolder('dir');
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
await openBackupsFolder('dir');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('open_backups_folder', { dir: 'dir' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('chooseFolder', () => {
|
|
||||||
it('returns null when not in Tauri', async () => {
|
|
||||||
expect(await chooseFolder()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns path when in Tauri and folder is chosen', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockResolvedValue('chosen_path');
|
|
||||||
expect(await chooseFolder()).toBe('chosen_path');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when in Tauri and folder choice is cancelled', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockResolvedValue(null);
|
|
||||||
expect(await chooseFolder()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dataDirHasDocuments', () => {
|
|
||||||
it('returns false when not in Tauri', async () => {
|
|
||||||
expect(await dataDirHasDocuments('dir')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
vi.mocked(invoke).mockResolvedValue(true);
|
|
||||||
expect(await dataDirHasDocuments('dir')).toBe(true);
|
|
||||||
expect(invoke).toHaveBeenCalledWith('data_dir_has_documents', { dir: 'dir' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('migrateDataDir', () => {
|
|
||||||
it('does nothing when not in Tauri', async () => {
|
|
||||||
await migrateDataDir('old', 'new', 'move');
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
await migrateDataDir('old', 'new', 'move');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('migrate_data_dir', { oldDir: 'old', newDir: 'new', strategy: 'move' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchRates', () => {
|
|
||||||
it('uses invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
const mockRates = { date: '2024-01-01', rates: { EUR: 0.85 }, fetchedAt: 12345 };
|
|
||||||
vi.mocked(invoke).mockResolvedValue(mockRates);
|
|
||||||
|
|
||||||
const result = await fetchRates(true);
|
|
||||||
expect(invoke).toHaveBeenCalledWith('fetch_rates', { force: true });
|
|
||||||
expect(result).toEqual(mockRates);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses fetch when not in Tauri', async () => {
|
|
||||||
const mockResponse = { result: 'success', time_last_update_utc: '2024-01-01', rates: { EUR: 0.85 } };
|
|
||||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
||||||
|
|
||||||
const result = await fetchRates();
|
|
||||||
expect(fetch).toHaveBeenCalledWith('https://open.er-api.com/v6/latest/USD');
|
|
||||||
expect(result?.date).toBe('2024-01-01');
|
|
||||||
expect(result?.rates).toEqual({ EUR: 0.85 });
|
|
||||||
expect(result?.fetchedAt).toBeTypeOf('number');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if fetch fails', async () => {
|
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
|
||||||
const result = await fetchRates();
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if API result is not success', async () => {
|
|
||||||
const mockResponse = { result: 'error', 'error-type': 'unsupported-code' };
|
|
||||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
||||||
const result = await fetchRates();
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetchHistoricalRates', () => {
|
|
||||||
it('uses invoke when in Tauri', async () => {
|
|
||||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
||||||
const mockRates = { EUR: 0.85 };
|
|
||||||
vi.mocked(invoke).mockResolvedValue(mockRates);
|
|
||||||
|
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
|
||||||
expect(invoke).toHaveBeenCalledWith('fetch_historical_rates', { date: '2024-01-01' });
|
|
||||||
expect(result).toEqual(mockRates);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses fetch when not in Tauri and formats rates correctly', async () => {
|
|
||||||
const mockResponse = { rates: { EUR: 0.85, GBP: 0.75, JPY: -1 } }; // negative value should be ignored
|
|
||||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
||||||
|
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
|
||||||
expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2024-01-01?from=USD');
|
|
||||||
expect(result).toEqual({ USD: 1, EUR: 0.85, GBP: 0.75 }); // USD: 1 added, negative ignored
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if fetch fails', async () => {
|
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null if rates missing in response', async () => {
|
|
||||||
const mockResponse = { amount: 1, base: 'USD', date: '2024-01-01' }; // missing rates
|
|
||||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
||||||
const result = await fetchHistoricalRates('2024-01-01');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
||||||
|
|
||||||
export interface DocMeta {
|
export interface DocMeta {
|
||||||
|
|
@ -6,7 +5,6 @@ export interface DocMeta {
|
||||||
title: string;
|
title: string;
|
||||||
/** user renamed the document — stop deriving the title from the first line */
|
/** user renamed the document — stop deriving the title from the first line */
|
||||||
customTitle?: boolean;
|
customTitle?: boolean;
|
||||||
pinned?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppData {
|
export interface AppData {
|
||||||
|
|
@ -33,10 +31,6 @@ export interface SettingsData {
|
||||||
deletedRetentionDays: number;
|
deletedRetentionDays: number;
|
||||||
/** keep the window above others */
|
/** keep the window above others */
|
||||||
alwaysOnTop: boolean;
|
alwaysOnTop: boolean;
|
||||||
/** date display layout; "system" also picks day/month order for ambiguous input like 01/02/2024 */
|
|
||||||
dateFormat: "system" | "iso" | "dmy" | "mdy";
|
|
||||||
/** background update checks at boot + every few hours; manual "Check for updates" always works regardless */
|
|
||||||
autoUpdateEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettingsData: SettingsData = {
|
export const defaultSettingsData: SettingsData = {
|
||||||
|
|
@ -53,8 +47,6 @@ export const defaultSettingsData: SettingsData = {
|
||||||
dataDir: "",
|
dataDir: "",
|
||||||
deletedRetentionDays: 14,
|
deletedRetentionDays: 14,
|
||||||
alwaysOnTop: false,
|
alwaysOnTop: false,
|
||||||
dateFormat: "system",
|
|
||||||
autoUpdateEnabled: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** "" -> undefined for the Rust side (default app data dir) */
|
/** "" -> undefined for the Rust side (default app data dir) */
|
||||||
|
|
@ -80,7 +72,7 @@ export async function loadSettings(): Promise<SettingsData> {
|
||||||
: localStorage.getItem("summarum.settings");
|
: localStorage.getItem("summarum.settings");
|
||||||
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("loadSettings failed", e);
|
console.warn("loadSettings failed", e);
|
||||||
}
|
}
|
||||||
return { ...defaultSettingsData };
|
return { ...defaultSettingsData };
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +92,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
|
||||||
: localStorage.getItem("summarum.documents");
|
: localStorage.getItem("summarum.documents");
|
||||||
if (raw) return JSON.parse(raw);
|
if (raw) return JSON.parse(raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("loadAppData failed", e);
|
console.warn("loadAppData failed", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +125,7 @@ export async function flushAppData(): Promise<void> {
|
||||||
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
|
||||||
else localStorage.setItem("summarum.documents", raw);
|
else localStorage.setItem("summarum.documents", raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("saveAppData failed", e);
|
console.warn("saveAppData failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +143,7 @@ export async function runBackups(dataDir: string, retentionDays: number): Promis
|
||||||
try {
|
try {
|
||||||
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("runBackups failed", e);
|
console.warn("runBackups failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +152,7 @@ export async function backupDeletedSheet(dataDir: string, title: string, content
|
||||||
try {
|
try {
|
||||||
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("backupDeletedSheet failed", e);
|
console.warn("backupDeletedSheet failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,10 +160,11 @@ export async function openBackupsFolder(dataDir: string): Promise<void> {
|
||||||
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
|
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */
|
|
||||||
export async function chooseFolder(): Promise<string | null> {
|
export async function chooseFolder(): Promise<string | null> {
|
||||||
if (!isTauri()) return null;
|
if (!isTauri()) return null;
|
||||||
return (await invoke<string | null>("pick_data_dir")) ?? null;
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const picked = await open({ directory: true, multiple: false });
|
||||||
|
return typeof picked === "string" ? picked : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dataDirHasDocuments(dir: string): Promise<boolean> {
|
export async function dataDirHasDocuments(dir: string): Promise<boolean> {
|
||||||
|
|
@ -206,71 +199,11 @@ export async function fetchRates(force = false): Promise<RatesPayload | null> {
|
||||||
return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
|
return { date: json.time_last_update_utc, rates: json.rates, fetchedAt: Math.floor(Date.now() / 1000) };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("fetchRates failed", e);
|
console.warn("fetchRates failed", e);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- historical rates
|
|
||||||
|
|
||||||
export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record<string, Record<string, number>>> {
|
|
||||||
if (dates.length === 0) return {};
|
|
||||||
try {
|
|
||||||
if (isTauri()) {
|
|
||||||
return await invoke<Record<string, Record<string, number>>>("fetch_historical_rates_batch", { dates });
|
|
||||||
}
|
|
||||||
const results: Record<string, Record<string, number>> = {};
|
|
||||||
await Promise.all(
|
|
||||||
dates.map(async (date) => {
|
|
||||||
const rates = await fetchHistoricalRates(date);
|
|
||||||
if (rates) results[date] = rates;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return results;
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("fetchHistoricalRatesBatch failed", e);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchHistoricalRates(date: string): Promise<Record<string, number> | null> {
|
|
||||||
try {
|
|
||||||
if (isTauri()) {
|
|
||||||
return await invoke<Record<string, number>>("fetch_historical_rates", { date });
|
|
||||||
}
|
|
||||||
const res = await fetch(`https://api.frankfurter.dev/v1/${date}?from=USD`);
|
|
||||||
const json = await res.json();
|
|
||||||
if (!json?.rates) return null;
|
|
||||||
const out: Record<string, number> = { USD: 1 };
|
|
||||||
for (const [k, v] of Object.entries(json.rates as Record<string, number>)) {
|
|
||||||
if (v > 0) out[k.toUpperCase()] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("fetchHistoricalRates failed", date, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- image export
|
|
||||||
|
|
||||||
export async function writeImageFile(dataBase64: string): Promise<boolean> {
|
|
||||||
if (!isTauri()) return false;
|
|
||||||
return invoke("write_image_file", { dataBase64 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- market data
|
|
||||||
|
|
||||||
export async function fetchMarketData(symbols: string[]): Promise<Record<string, number>> {
|
|
||||||
if (!isTauri() || symbols.length === 0) return {};
|
|
||||||
try {
|
|
||||||
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("fetchMarketData failed", e);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- extensions
|
// ---------- extensions
|
||||||
|
|
||||||
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
export async function loadExtensionScripts(): Promise<{ name: string; code: string }[]> {
|
||||||
|
|
@ -278,7 +211,7 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri
|
||||||
try {
|
try {
|
||||||
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
return await invoke<{ name: string; code: string }[]>("load_extensions");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("loadExtensions failed", e);
|
console.warn("loadExtensions failed", e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +248,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
|
||||||
const content = await invoke<string>("read_text_file", { path });
|
const content = await invoke<string>("read_text_file", { path });
|
||||||
cb(content);
|
cb(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn("file drop rejected", path, e);
|
console.warn("file drop rejected", path, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
195
src/ui/app.css
195
src/ui/app.css
|
|
@ -21,7 +21,6 @@
|
||||||
--border: #e4e4e0;
|
--border: #e4e4e0;
|
||||||
--sel: #cfe3f7;
|
--sel: #cfe3f7;
|
||||||
--caret: #d57d2c;
|
--caret: #d57d2c;
|
||||||
--error: #c1443a;
|
|
||||||
--toolbar-bg: #fafaf8;
|
--toolbar-bg: #fafaf8;
|
||||||
--hover: #ececea;
|
--hover: #ececea;
|
||||||
}
|
}
|
||||||
|
|
@ -42,33 +41,11 @@
|
||||||
--border: #333438;
|
--border: #333438;
|
||||||
--sel: #2d4a68;
|
--sel: #2d4a68;
|
||||||
--caret: #e8923c;
|
--caret: #e8923c;
|
||||||
--error: #e2685c;
|
|
||||||
--toolbar-bg: #232428;
|
--toolbar-bg: #232428;
|
||||||
--hover: #2f3035;
|
--hover: #2f3035;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
/* app-wide: buttons/list items/status text ease their hover and state
|
|
||||||
changes instead of snapping; high-frequency UI (results overlay,
|
|
||||||
autocomplete, drag handles) is deliberately excluded below */
|
|
||||||
button, .doc-item, .search-item, #rates-info, #total-line {
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
/* thin scrollbars everywhere (WebView2 is Chromium — ::-webkit-* applies) */
|
|
||||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--fg-muted); }
|
|
||||||
/* keyboard-only focus ring (mouse clicks don't trigger :focus-visible) on the
|
|
||||||
form fields that otherwise zero out — or never set — a visible outline */
|
|
||||||
#doc-title:focus-visible,
|
|
||||||
#search-input:focus-visible,
|
|
||||||
#settings-panel input:focus-visible,
|
|
||||||
#settings-panel select:focus-visible,
|
|
||||||
.path-btn:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
html, body { margin: 0; height: 100%; }
|
html, body { margin: 0; height: 100%; }
|
||||||
body {
|
body {
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
|
|
@ -89,20 +66,13 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
overflow: hidden;
|
|
||||||
transition: width 0.18s ease, min-width 0.18s ease, padding 0.18s ease, border-right-color 0.18s ease;
|
|
||||||
}
|
|
||||||
#sidebar.hidden {
|
|
||||||
width: 0;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-right-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
#sidebar.hidden { display: none; }
|
||||||
#doc-list { flex: 1; overflow-y: auto; }
|
#doc-list { flex: 1; overflow-y: auto; }
|
||||||
.doc-item {
|
.doc-item {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: grab;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -110,40 +80,26 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border-top: 2px solid transparent;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease, opacity 0.15s ease, border-top-color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.doc-item:hover { background: var(--hover); }
|
.doc-item:hover { background: var(--hover); }
|
||||||
.doc-item.active { background: var(--sel); }
|
.doc-item.active { background: var(--sel); }
|
||||||
.doc-item.dragging { opacity: 0.4; }
|
|
||||||
.doc-item.drag-over { border-top-color: var(--accent); }
|
|
||||||
.doc-name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.doc-item .pin-btn,
|
|
||||||
.doc-item .del {
|
.doc-item .del {
|
||||||
flex-shrink: 0;
|
margin-left: auto;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 4px;
|
padding: 2px 6px;
|
||||||
line-height: 1;
|
|
||||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.doc-item:hover .pin-btn,
|
|
||||||
.doc-item:hover .del { opacity: 0.7; }
|
|
||||||
.doc-item .pin-btn.active { opacity: 1; }
|
|
||||||
.doc-item:hover .pin-btn.active { opacity: 1; }
|
|
||||||
.doc-item .del { font-size: 12px; padding: 2px 6px; }
|
|
||||||
.doc-item:hover .del { opacity: 1; }
|
.doc-item:hover .del { opacity: 1; }
|
||||||
.doc-item .del.confirm {
|
.doc-item .del.confirm {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: #d63a3a;
|
background: #d63a3a;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.doc-item.pinned .doc-name { font-weight: 500; }
|
|
||||||
#new-doc {
|
#new-doc {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -215,18 +171,14 @@ body {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
#rates-info, #market-info {
|
#rates-info {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
#rates-info:hover, #market-info:hover { color: var(--fg); }
|
#rates-info:hover { color: var(--fg); }
|
||||||
#rates-info.spin, #market-info.spin { pointer-events: none; animation: rates-pulse 1s ease-in-out infinite; }
|
#rates-info.spin { opacity: 0.5; pointer-events: none; }
|
||||||
@keyframes rates-pulse {
|
|
||||||
0%, 100% { opacity: 0.5; }
|
|
||||||
50% { opacity: 0.95; }
|
|
||||||
}
|
|
||||||
#total-line {
|
#total-line {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -259,18 +211,8 @@ body {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform-origin: bottom right;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: scale(0.97) translateY(4px);
|
|
||||||
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0s linear 0.12s;
|
|
||||||
}
|
|
||||||
#export-menu:not(.hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0s linear 0s;
|
|
||||||
}
|
}
|
||||||
|
#export-menu.hidden { display: none; }
|
||||||
#export-menu button {
|
#export-menu button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -379,16 +321,6 @@ body {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.result-line:hover { text-decoration: underline; }
|
.result-line:hover { text-decoration: underline; }
|
||||||
.result-line.result-error {
|
|
||||||
color: var(--error);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.result-sparkline {
|
|
||||||
position: absolute;
|
|
||||||
right: 14px;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- settings */
|
/* ---------- settings */
|
||||||
#settings-panel {
|
#settings-panel {
|
||||||
|
|
@ -402,58 +334,9 @@ body {
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
transform-origin: top right;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: scale(0.97) translateY(-4px);
|
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0.15s;
|
|
||||||
}
|
|
||||||
#settings-panel:not(.hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0s;
|
|
||||||
}
|
}
|
||||||
|
#settings-panel.hidden { display: none; }
|
||||||
#settings-panel h2 { margin: 0 0 10px; font-size: 14px; }
|
#settings-panel h2 { margin: 0 0 10px; font-size: 14px; }
|
||||||
#settings-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
#settings-tabs .tab {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--fg-muted);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
#settings-tabs .tab:hover { background: var(--hover); color: var(--fg); }
|
|
||||||
#settings-tabs .tab.active { background: var(--sel); color: var(--fg); }
|
|
||||||
.settings-tabpanel.hidden { display: none; }
|
|
||||||
.settings-tabpanel:not(.hidden) { animation: settings-fade-in 0.12s ease; }
|
|
||||||
@keyframes settings-fade-in {
|
|
||||||
from { opacity: 0; transform: translateY(2px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.settings-tabpanel-buttons { display: flex; gap: 6px; margin-top: 10px; }
|
|
||||||
.settings-tabpanel-buttons button {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
.settings-tabpanel-buttons button:hover { background: var(--hover); }
|
|
||||||
.settings-version { color: var(--fg-muted); font-size: 11.5px; margin-top: 10px; }
|
|
||||||
#settings-panel label {
|
#settings-panel label {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -472,37 +355,6 @@ body {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
width: 110px;
|
width: 110px;
|
||||||
}
|
}
|
||||||
#settings-panel input[type="range"] {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 110px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#settings-panel input[type="range"]::-webkit-slider-runnable-track {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
#settings-panel input[type="range"]::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
cursor: grab;
|
|
||||||
transition: transform 0.1s ease;
|
|
||||||
}
|
|
||||||
#settings-panel input[type="range"]:active::-webkit-slider-thumb {
|
|
||||||
cursor: grabbing;
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
.path-btn {
|
.path-btn {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
|
@ -519,7 +371,7 @@ body {
|
||||||
}
|
}
|
||||||
.path-btn:hover { background: var(--hover); }
|
.path-btn:hover { background: var(--hover); }
|
||||||
|
|
||||||
.settings-footer { display: flex; justify-content: flex-end; gap: 6px; margin-top: 12px; }
|
.settings-footer { display: flex; justify-content: space-between; gap: 6px; margin-top: 12px; }
|
||||||
.settings-footer button {
|
.settings-footer button {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
@ -557,15 +409,8 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s linear 0.15s;
|
|
||||||
}
|
|
||||||
#modal:not(.hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s linear 0s;
|
|
||||||
}
|
}
|
||||||
|
#modal.hidden { display: none; }
|
||||||
.modal-box {
|
.modal-box {
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -573,10 +418,7 @@ body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||||
transform: scale(0.96) translateY(-6px);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
}
|
}
|
||||||
#modal:not(.hidden) .modal-box { transform: scale(1) translateY(0); }
|
|
||||||
.modal-box p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; }
|
.modal-box p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; }
|
||||||
.modal-btns { display: flex; gap: 8px; justify-content: flex-end; }
|
.modal-btns { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
.modal-btns button {
|
.modal-btns button {
|
||||||
|
|
@ -601,29 +443,19 @@ body {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 12%;
|
padding-top: 12%;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s linear 0.15s;
|
|
||||||
}
|
|
||||||
#search-overlay:not(.hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transition: opacity 0.15s ease, visibility 0s linear 0s;
|
|
||||||
}
|
}
|
||||||
|
#search-overlay.hidden { display: none; }
|
||||||
.search-box {
|
.search-box {
|
||||||
background: var(--bg-soft);
|
background: var(--bg-soft);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||||
transform: scale(0.96) translateY(-6px);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
width: min(440px, 90vw);
|
width: min(440px, 90vw);
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
#search-overlay:not(.hidden) .search-box { transform: scale(1) translateY(0); }
|
|
||||||
#search-input {
|
#search-input {
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
@ -654,4 +486,3 @@ body {
|
||||||
}
|
}
|
||||||
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
|
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
|
||||||
.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
|
.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
|
||||||
.d-none { display: none !important; }
|
|
||||||
|
|
|
||||||
103
src/ui/editor.ts
103
src/ui/editor.ts
|
|
@ -1,11 +1,10 @@
|
||||||
// CodeMirror editor wired to the engine: evaluates on change, highlights
|
// CodeMirror editor wired to the engine: evaluates on change, highlights
|
||||||
// engine tokens, renders the results overlay, autocompletes phrases.
|
// engine tokens, renders the results overlay, autocompletes phrases.
|
||||||
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
|
import { EditorView, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
|
||||||
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
|
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
|
||||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||||
import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
|
import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
|
||||||
import { SumEngine, LineResult } from "../engine";
|
import { SumEngine, LineResult } from "../engine";
|
||||||
import { buildSparkline } from "./sparkline";
|
|
||||||
|
|
||||||
export interface EditorCallbacks {
|
export interface EditorCallbacks {
|
||||||
onChange(text: string): void;
|
onChange(text: string): void;
|
||||||
|
|
@ -41,7 +40,7 @@ function buildDecorations(state: EditorState): DecorationSet {
|
||||||
let cls: string | null = null;
|
let cls: string | null = null;
|
||||||
switch (tk.t) {
|
switch (tk.t) {
|
||||||
case "num": case "const": case "scale": cls = "tok-number"; break;
|
case "num": case "const": case "scale": cls = "tok-number"; break;
|
||||||
case "unit": case "currency": case "repr": case "date": case "datelit": cls = "tok-unit"; break;
|
case "unit": case "currency": case "repr": case "date": cls = "tok-unit"; break;
|
||||||
case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
|
case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
|
||||||
case "func": case "agg": cls = "tok-var"; break;
|
case "func": case "agg": cls = "tok-var"; break;
|
||||||
default: cls = null;
|
default: cls = null;
|
||||||
|
|
@ -66,17 +65,12 @@ function buildDecorations(state: EditorState): DecorationSet {
|
||||||
|
|
||||||
const highlightField = StateField.define<DecorationSet>({
|
const highlightField = StateField.define<DecorationSet>({
|
||||||
create: buildDecorations,
|
create: buildDecorations,
|
||||||
update(value, tr) {
|
update(_value, tr) {
|
||||||
const hasResults = tr.effects.some((e) => e.is(setResults));
|
return buildDecorations(tr.state);
|
||||||
if (hasResults) return buildDecorations(tr.state);
|
|
||||||
if (tr.docChanged) return value.map(tr.changes);
|
|
||||||
return value;
|
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
});
|
});
|
||||||
|
|
||||||
const varHighlightReCache = new Map<string, { assignment: RegExp; highlight: RegExp }>();
|
|
||||||
|
|
||||||
/** Highlights every occurrence of the variable under the cursor. */
|
/** Highlights every occurrence of the variable under the cursor. */
|
||||||
function buildVarHighlight(state: EditorState): DecorationSet {
|
function buildVarHighlight(state: EditorState): DecorationSet {
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
|
@ -92,68 +86,24 @@ function buildVarHighlight(state: EditorState): DecorationSet {
|
||||||
if (a === b) return builder.finish();
|
if (a === b) return builder.finish();
|
||||||
const word = text.slice(a, b);
|
const word = text.slice(a, b);
|
||||||
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
|
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
|
||||||
|
|
||||||
let cachedRe = varHighlightReCache.get(word);
|
|
||||||
if (!cachedRe) {
|
|
||||||
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
cachedRe = {
|
|
||||||
assignment: new RegExp(`^\\s*${esc}\\s*=`, "mu"),
|
|
||||||
highlight: new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu"),
|
|
||||||
};
|
|
||||||
if (varHighlightReCache.size > 1000) varHighlightReCache.clear();
|
|
||||||
varHighlightReCache.set(word, cachedRe);
|
|
||||||
}
|
|
||||||
|
|
||||||
// only highlight if the word is actually assigned somewhere in the document
|
// only highlight if the word is actually assigned somewhere in the document
|
||||||
if (!cachedRe.assignment.test(text)) return builder.finish();
|
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
for (const m of text.matchAll(cachedRe.highlight)) {
|
if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish();
|
||||||
|
const re = new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
|
||||||
|
for (const m of text.matchAll(re)) {
|
||||||
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
|
||||||
}
|
}
|
||||||
return builder.finish();
|
return builder.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
const setVarHighlight = StateEffect.define<DecorationSet>();
|
|
||||||
|
|
||||||
const varHighlightField = StateField.define<DecorationSet>({
|
const varHighlightField = StateField.define<DecorationSet>({
|
||||||
create: () => Decoration.none,
|
create: buildVarHighlight,
|
||||||
update(value, tr) {
|
update(_value, tr) {
|
||||||
for (const e of tr.effects) {
|
return buildVarHighlight(tr.state);
|
||||||
if (e.is(setVarHighlight)) return e.value;
|
|
||||||
}
|
|
||||||
if (tr.docChanged) return value.map(tr.changes);
|
|
||||||
return value;
|
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
});
|
});
|
||||||
|
|
||||||
const varHighlightPlugin = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
timeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
|
||||||
this.schedule(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.docChanged || update.selectionSet) {
|
|
||||||
this.schedule(update.view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule(view: EditorView) {
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
|
||||||
this.timeout = setTimeout(() => {
|
|
||||||
const decos = buildVarHighlight(view.state);
|
|
||||||
view.dispatch({ effects: setVarHighlight.of(decos) });
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export class SumEditor {
|
export class SumEditor {
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
private resultsEl: HTMLElement;
|
private resultsEl: HTMLElement;
|
||||||
|
|
@ -164,13 +114,11 @@ export class SumEditor {
|
||||||
resultsEl: HTMLElement,
|
resultsEl: HTMLElement,
|
||||||
private engine: SumEngine,
|
private engine: SumEngine,
|
||||||
private cb: EditorCallbacks,
|
private cb: EditorCallbacks,
|
||||||
private evaluateDoc: (text: string) => LineResult[],
|
|
||||||
initialText: string,
|
initialText: string,
|
||||||
) {
|
) {
|
||||||
this.resultsEl = resultsEl;
|
this.resultsEl = resultsEl;
|
||||||
|
|
||||||
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
||||||
const varPattern = /^\s*([\p{L}_][\p{L}\d_]*)\s*=/u;
|
|
||||||
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
||||||
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
||||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||||
|
|
@ -183,16 +131,10 @@ export class SumEditor {
|
||||||
}));
|
}));
|
||||||
const options = [...engineOptions];
|
const options = [...engineOptions];
|
||||||
// document variables
|
// document variables
|
||||||
const iter = ctx.state.doc.iterLines();
|
const text = ctx.state.doc.toString();
|
||||||
for (let next = iter.next(); !next.done; next = iter.next()) {
|
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
|
||||||
const line = next.value;
|
|
||||||
if (line.includes("=")) {
|
|
||||||
const m = varPattern.exec(line);
|
|
||||||
if (m) {
|
|
||||||
options.push({ label: m[1], type: "variable" });
|
options.push({ label: m[1], type: "variable" });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -208,7 +150,6 @@ export class SumEditor {
|
||||||
resultsField,
|
resultsField,
|
||||||
highlightField,
|
highlightField,
|
||||||
varHighlightField,
|
varHighlightField,
|
||||||
varHighlightPlugin,
|
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
drawSelection({ cursorBlinkRate: 1000 }),
|
drawSelection({ cursorBlinkRate: 1000 }),
|
||||||
// CM injects its base styles at runtime after app.css, so layout
|
// CM injects its base styles at runtime after app.css, so layout
|
||||||
|
|
@ -323,7 +264,7 @@ export class SumEditor {
|
||||||
}
|
}
|
||||||
|
|
||||||
private evaluate(text: string): void {
|
private evaluate(text: string): void {
|
||||||
this.results = this.evaluateDoc(text);
|
this.results = this.engine.evaluateDocument(text);
|
||||||
this.view.dispatch({ effects: setResults.of(this.results) });
|
this.view.dispatch({ effects: setResults.of(this.results) });
|
||||||
this.cb.onResults(this.results);
|
this.cb.onResults(this.results);
|
||||||
}
|
}
|
||||||
|
|
@ -331,29 +272,23 @@ export class SumEditor {
|
||||||
private renderResults(): void {
|
private renderResults(): void {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
const doc = this.view.state.doc;
|
const doc = this.view.state.doc;
|
||||||
|
// documentTop is in screen coordinates; place results relative to the overlay
|
||||||
const overlayTop = this.resultsEl.getBoundingClientRect().top;
|
const overlayTop = this.resultsEl.getBoundingClientRect().top;
|
||||||
const docTop = this.view.documentTop;
|
const docTop = this.view.documentTop;
|
||||||
for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
|
for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
|
||||||
const r = this.results[i];
|
const r = this.results[i];
|
||||||
const isChart = r.value?.kind === "chart";
|
if (!r.text) continue;
|
||||||
const isError = !!r.error;
|
|
||||||
if (!r.text && !isChart && !isError) continue;
|
|
||||||
const line = doc.line(i + 1);
|
const line = doc.line(i + 1);
|
||||||
const block = this.view.lineBlockAt(line.from);
|
const block = this.view.lineBlockAt(line.from);
|
||||||
const top = docTop + block.top - overlayTop;
|
const top = docTop + block.top - overlayTop;
|
||||||
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
|
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
|
||||||
if (isChart && r.value?.kind === "chart") {
|
|
||||||
frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top));
|
|
||||||
} else {
|
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "result-line" + (isError ? " result-error" : "");
|
el.className = "result-line";
|
||||||
el.textContent = isError ? "#ref?" : r.text;
|
el.textContent = r.text;
|
||||||
if (!isError) el.dataset.value = r.text!;
|
el.dataset.value = r.text;
|
||||||
if (isError) el.title = r.error!;
|
|
||||||
el.style.top = `${top}px`;
|
el.style.top = `${top}px`;
|
||||||
frag.appendChild(el);
|
frag.appendChild(el);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.resultsEl.replaceChildren(frag);
|
this.resultsEl.replaceChildren(frag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { exportSheetImage } from './export';
|
|
||||||
import { writeImageFile } from '../storage';
|
|
||||||
|
|
||||||
vi.mock('../storage', () => ({
|
|
||||||
writeImageFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('exportSheetImage', () => {
|
|
||||||
let mockContext2D: any;
|
|
||||||
let mockCanvas: any;
|
|
||||||
let toastMock: any;
|
|
||||||
let tMock: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
toastMock = vi.fn();
|
|
||||||
tMock = vi.fn((key: string) => `trans_${key}`);
|
|
||||||
|
|
||||||
mockContext2D = {
|
|
||||||
measureText: vi.fn().mockReturnValue({ width: 10 }),
|
|
||||||
scale: vi.fn(),
|
|
||||||
fillRect: vi.fn(),
|
|
||||||
fillText: vi.fn(),
|
|
||||||
font: '',
|
|
||||||
fillStyle: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
mockCanvas = {
|
|
||||||
getContext: vi.fn().mockReturnValue(mockContext2D),
|
|
||||||
toBlob: vi.fn((cb) => cb(new Blob(['fake-image-data'], { type: 'image/png' }))),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.stubGlobal('document', {
|
|
||||||
documentElement: {},
|
|
||||||
createElement: vi.fn((tag) => {
|
|
||||||
if (tag === 'canvas') return mockCanvas;
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('window', {
|
|
||||||
devicePixelRatio: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('getComputedStyle', vi.fn().mockReturnValue({
|
|
||||||
getPropertyValue: vi.fn((prop) => {
|
|
||||||
if (prop === '--bg') return '';
|
|
||||||
if (prop === '--fg') return '';
|
|
||||||
if (prop === '--result') return '';
|
|
||||||
if (prop === '--mono') return '';
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.stubGlobal('Blob', class Blob {
|
|
||||||
content: any[];
|
|
||||||
options: any;
|
|
||||||
constructor(content: any[], options: any) {
|
|
||||||
this.content = content;
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
async arrayBuffer() {
|
|
||||||
return new Uint8Array([1, 2, 3]).buffer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('ClipboardItem', class ClipboardItem {
|
|
||||||
constructor(data: any) {
|
|
||||||
Object.assign(this, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('navigator', {
|
|
||||||
clipboard: {
|
|
||||||
write: vi.fn().mockResolvedValue(undefined)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('btoa', (str: string) => Buffer.from(str, 'binary').toString('base64'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a canvas and copy it to clipboard successfully', async () => {
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['1 + 1'],
|
|
||||||
results: [{ text: '2' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(document.createElement).toHaveBeenCalledWith('canvas');
|
|
||||||
expect(mockCanvas.getContext).toHaveBeenCalledWith('2d');
|
|
||||||
expect(mockContext2D.fillText).toHaveBeenCalledWith('1 + 1', expect.any(Number), expect.any(Number));
|
|
||||||
expect(mockContext2D.fillText).toHaveBeenCalledWith('2', expect.any(Number), expect.any(Number));
|
|
||||||
|
|
||||||
expect(navigator.clipboard.write).toHaveBeenCalled();
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_imageCopied');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle blob failure', async () => {
|
|
||||||
mockCanvas.toBlob = vi.fn((cb) => cb(null));
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['test'],
|
|
||||||
results: [{ text: null }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
|
|
||||||
expect(navigator.clipboard.write).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback to writeImageFile when clipboard write fails', async () => {
|
|
||||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
|
||||||
vi.mocked(writeImageFile).mockResolvedValue(true);
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['fallback'],
|
|
||||||
results: [{ text: 'res' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(navigator.clipboard.write).toHaveBeenCalled();
|
|
||||||
expect(writeImageFile).toHaveBeenCalled();
|
|
||||||
expect(writeImageFile).toHaveBeenCalledWith('AQID');
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_saved');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle writeImageFile failure in fallback', async () => {
|
|
||||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
|
||||||
vi.mocked(writeImageFile).mockRejectedValue(new Error('save fail'));
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['fail'],
|
|
||||||
results: [{ text: 'res' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(writeImageFile).toHaveBeenCalled();
|
|
||||||
expect(toastMock).toHaveBeenCalledWith('trans_imageFailed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle writeImageFile returning false', async () => {
|
|
||||||
navigator.clipboard.write = vi.fn().mockRejectedValue(new Error('clipboard fail'));
|
|
||||||
vi.mocked(writeImageFile).mockResolvedValue(false);
|
|
||||||
|
|
||||||
await exportSheetImage({
|
|
||||||
lines: ['fail'],
|
|
||||||
results: [{ text: 'res' }],
|
|
||||||
fontSize: 14,
|
|
||||||
toast: toastMock,
|
|
||||||
t: tMock
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(writeImageFile).toHaveBeenCalled();
|
|
||||||
expect(toastMock).not.toHaveBeenCalledWith('trans_saved');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
// Renders the active sheet + its results to a PNG and copies it to the
|
|
||||||
// clipboard, falling back to a Save As dialog when the Clipboard API is
|
|
||||||
// unavailable (e.g. the window lost focus).
|
|
||||||
import { writeImageFile } from "../storage";
|
|
||||||
|
|
||||||
export interface ExportImageDeps {
|
|
||||||
lines: string[];
|
|
||||||
results: { text: string | null }[];
|
|
||||||
fontSize: number;
|
|
||||||
toast(msg: string): void;
|
|
||||||
t(key: string): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
|
|
||||||
const { lines: rawLines, results: doc, fontSize, toast, t } = deps;
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const style = getComputedStyle(document.documentElement);
|
|
||||||
const bgColor = style.getPropertyValue("--bg").trim() || "#ffffff";
|
|
||||||
const fgColor = style.getPropertyValue("--fg").trim() || "#333333";
|
|
||||||
const resultColor = style.getPropertyValue("--result").trim() || "#d57d2c";
|
|
||||||
const monoFont = style.getPropertyValue("--mono").trim() || "monospace";
|
|
||||||
const lineH = Math.round(fontSize * 1.7);
|
|
||||||
const padX = 20, padTop = 16, padBot = 16;
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx2 = canvas.getContext("2d")!;
|
|
||||||
ctx2.font = `${fontSize}px ${monoFont}`;
|
|
||||||
const maxWidth = rawLines.reduce((m, l) => Math.max(m, ctx2.measureText(l).width), 0) + padX * 4 + 150;
|
|
||||||
const w = Math.max(400, maxWidth);
|
|
||||||
const h = padTop + rawLines.length * lineH + padBot;
|
|
||||||
|
|
||||||
canvas.width = w * dpr;
|
|
||||||
canvas.height = h * dpr;
|
|
||||||
ctx2.scale(dpr, dpr);
|
|
||||||
|
|
||||||
ctx2.fillStyle = bgColor;
|
|
||||||
ctx2.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
ctx2.font = `${fontSize}px ${monoFont}`;
|
|
||||||
for (let i = 0; i < rawLines.length; i++) {
|
|
||||||
const y = padTop + i * lineH + fontSize;
|
|
||||||
ctx2.fillStyle = fgColor;
|
|
||||||
ctx2.fillText(rawLines[i], padX, y);
|
|
||||||
const res = doc[i]?.text;
|
|
||||||
if (res) {
|
|
||||||
ctx2.fillStyle = resultColor;
|
|
||||||
const rx = w - padX - ctx2.measureText(res).width;
|
|
||||||
ctx2.fillText(res, rx, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob | null>((res) => canvas.toBlob(res, "image/png"));
|
|
||||||
if (!blob) { toast(t("imageFailed")); return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
||||||
toast(t("imageCopied"));
|
|
||||||
} catch {
|
|
||||||
// clipboard API failed — save to file
|
|
||||||
try {
|
|
||||||
const buf = await blob.arrayBuffer();
|
|
||||||
const b64 = bytesToBase64(new Uint8Array(buf));
|
|
||||||
if (await writeImageFile(b64)) {
|
|
||||||
toast(t("saved"));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast(t("imageFailed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** chunked to avoid "Maximum call stack size exceeded" from spreading a large array as args */
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
|
||||||
let bin = "";
|
|
||||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
||||||
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
||||||
}
|
|
||||||
return btoa(bin);
|
|
||||||
}
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { SumEngine } from "../engine";
|
|
||||||
import { parseResultQuery, searchAllSheets, SearchDeps, SearchDoc } from "./search";
|
|
||||||
import { Workspace } from "../workspace";
|
|
||||||
import Decimal from "decimal.js";
|
|
||||||
|
|
||||||
describe("parseResultQuery", () => {
|
|
||||||
const engine = new SumEngine();
|
|
||||||
|
|
||||||
it("parses '>=' operator correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, ">= 100");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe(">=");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(100))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses '<=' operator correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, "<= 50");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe("<=");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(50))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses '>' operator correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, "> 0");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe(">");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(0))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses '<' operator correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, "< -10");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe("<");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(-10))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses '=' operator correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, "= 42.5");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe("=");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(42.5))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses '~' operator correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, "~ 3.14");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe("~");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(3.14))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles whitespace correctly", () => {
|
|
||||||
const res = parseResultQuery(engine, " >= 200 ");
|
|
||||||
expect(res).not.toBeNull();
|
|
||||||
expect(res?.op).toBe(">=");
|
|
||||||
expect(res?.threshold.kind).toBe("quantity");
|
|
||||||
if (res?.threshold.kind === "quantity") {
|
|
||||||
expect(res.threshold.value.eq(new Decimal(200))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for invalid operator", () => {
|
|
||||||
const res = parseResultQuery(engine, "!= 100");
|
|
||||||
expect(res).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for invalid value string", () => {
|
|
||||||
// "abc" cannot be evaluated to a quantity
|
|
||||||
const res = parseResultQuery(engine, ">= abc");
|
|
||||||
expect(res).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for empty string", () => {
|
|
||||||
const res = parseResultQuery(engine, "");
|
|
||||||
expect(res).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when no expression matches after operator", () => {
|
|
||||||
const res = parseResultQuery(engine, ">=");
|
|
||||||
expect(res).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("searchAllSheets", () => {
|
|
||||||
it("returns empty array for empty query", () => {
|
|
||||||
const deps = {} as SearchDeps;
|
|
||||||
expect(searchAllSheets(deps, " ")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("performs full-text search across multiple sheets", () => {
|
|
||||||
const docs: SearchDoc[] = [
|
|
||||||
{ id: "1", title: "Doc 1", text: "Hello world\nThis is a test" },
|
|
||||||
{ id: "2", title: "Doc 2", text: "Another test\nNothing here" },
|
|
||||||
];
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine: new SumEngine(),
|
|
||||||
} as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "test");
|
|
||||||
expect(hits).toHaveLength(2);
|
|
||||||
expect(hits[0]).toMatchObject({ docId: "1", docTitle: "Doc 1", line: 2, text: "This is a test" });
|
|
||||||
expect(hits[1]).toMatchObject({ docId: "2", docTitle: "Doc 2", line: 1, text: "Another test" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("limits results to 200 hits for text search", () => {
|
|
||||||
const lines = Array(250).fill("test string");
|
|
||||||
const docs: SearchDoc[] = [
|
|
||||||
{ id: "1", title: "Doc 1", text: lines.join("\n") },
|
|
||||||
];
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine: new SumEngine(),
|
|
||||||
} as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "test");
|
|
||||||
expect(hits).toHaveLength(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("numeric threshold search", () => {
|
|
||||||
const engine = new SumEngine();
|
|
||||||
|
|
||||||
const createMockWorkspace = (results: any[]) => {
|
|
||||||
return {
|
|
||||||
getCachedResults: vi.fn().mockReturnValue(results)
|
|
||||||
} as unknown as Workspace;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("matches exact value (=)", () => {
|
|
||||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100" }];
|
|
||||||
const workspace = createMockWorkspace([{
|
|
||||||
value: { kind: "quantity", value: new Decimal(100) },
|
|
||||||
text: "100"
|
|
||||||
}]);
|
|
||||||
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine,
|
|
||||||
workspace,
|
|
||||||
} as unknown as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "= 100");
|
|
||||||
expect(hits).toHaveLength(1);
|
|
||||||
expect(hits[0]).toMatchObject({ docId: "1", line: 1, result: "100" });
|
|
||||||
|
|
||||||
const noHits = searchAllSheets(deps, "= 99");
|
|
||||||
expect(noHits).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches greater than (>)", () => {
|
|
||||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "150\n50" }];
|
|
||||||
const workspace = createMockWorkspace([
|
|
||||||
{ value: { kind: "quantity", value: new Decimal(150) }, text: "150" },
|
|
||||||
{ value: { kind: "quantity", value: new Decimal(50) }, text: "50" }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine,
|
|
||||||
workspace,
|
|
||||||
} as unknown as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "> 100");
|
|
||||||
expect(hits).toHaveLength(1);
|
|
||||||
expect(hits[0].line).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches approximate value (~)", () => {
|
|
||||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100.5\n99.5\n105" }];
|
|
||||||
const workspace = createMockWorkspace([
|
|
||||||
{ value: { kind: "quantity", value: new Decimal(100.5) }, text: "100.5" },
|
|
||||||
{ value: { kind: "quantity", value: new Decimal(99.5) }, text: "99.5" },
|
|
||||||
{ value: { kind: "quantity", value: new Decimal(105) }, text: "105" }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine,
|
|
||||||
workspace,
|
|
||||||
} as unknown as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "~ 100");
|
|
||||||
expect(hits).toHaveLength(2); // 100.5 and 99.5 are within 1% of 100
|
|
||||||
expect(hits[0].line).toBe(1);
|
|
||||||
expect(hits[1].line).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-quantity results during numeric search", () => {
|
|
||||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: "100\nnot a number" }];
|
|
||||||
const workspace = createMockWorkspace([
|
|
||||||
{ value: { kind: "quantity", value: new Decimal(100) }, text: "100" },
|
|
||||||
{ value: null, text: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine,
|
|
||||||
workspace,
|
|
||||||
} as unknown as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "> 50");
|
|
||||||
expect(hits).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("limits results to 200 hits for numeric search", () => {
|
|
||||||
const lines = Array(250).fill("100");
|
|
||||||
const docs: SearchDoc[] = [{ id: "1", title: "Doc 1", text: lines.join("\n") }];
|
|
||||||
|
|
||||||
const results = Array(250).fill({
|
|
||||||
value: { kind: "quantity", value: new Decimal(100) },
|
|
||||||
text: "100"
|
|
||||||
});
|
|
||||||
const workspace = createMockWorkspace(results);
|
|
||||||
|
|
||||||
const deps = {
|
|
||||||
docs: () => docs,
|
|
||||||
engine,
|
|
||||||
workspace,
|
|
||||||
} as unknown as SearchDeps;
|
|
||||||
|
|
||||||
const hits = searchAllSheets(deps, "> 50");
|
|
||||||
expect(hits).toHaveLength(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
184
src/ui/search.ts
184
src/ui/search.ts
|
|
@ -1,184 +0,0 @@
|
||||||
// Full-text and numeric ("> 100", "~ 50") search across every open sheet.
|
|
||||||
// Owns the #search-overlay DOM and its own input debounce.
|
|
||||||
import { SumEngine, Value } from "../engine";
|
|
||||||
import { Workspace } from "../workspace";
|
|
||||||
|
|
||||||
export interface SearchHit {
|
|
||||||
docId: string;
|
|
||||||
docTitle: string;
|
|
||||||
line: number;
|
|
||||||
text: string;
|
|
||||||
result?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchDoc {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchDeps {
|
|
||||||
engine: SumEngine;
|
|
||||||
workspace: Workspace;
|
|
||||||
docs(): SearchDoc[];
|
|
||||||
t(key: string): string;
|
|
||||||
/** switch to docId (if not already active) and put the caret on line (1-based) */
|
|
||||||
onOpen(docId: string, line: number): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchController {
|
|
||||||
open(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $ = <T extends HTMLElement>(sel: string): T =>
|
|
||||||
document.querySelector(sel) as T;
|
|
||||||
|
|
||||||
export function parseResultQuery(
|
|
||||||
engine: SumEngine,
|
|
||||||
q: string,
|
|
||||||
): { op: string; threshold: Value } | null {
|
|
||||||
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
|
|
||||||
if (!m) return null;
|
|
||||||
const v = engine.evaluateExpression(m[2].trim());
|
|
||||||
if (!v || v.kind !== "quantity") return null;
|
|
||||||
return { op: m[1], threshold: v };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q) return [];
|
|
||||||
const docs = deps.docs();
|
|
||||||
const rq = parseResultQuery(deps.engine, q);
|
|
||||||
if (rq && rq.threshold.kind === "quantity") {
|
|
||||||
const th = rq.threshold.value;
|
|
||||||
const hits: SearchHit[] = [];
|
|
||||||
for (const doc of docs) {
|
|
||||||
const results = deps.workspace.getCachedResults(doc);
|
|
||||||
let lines: string[] | undefined;
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
|
||||||
const r = results[i];
|
|
||||||
if (!r.value || r.value.kind !== "quantity") continue;
|
|
||||||
const v = r.value.value;
|
|
||||||
let match = false;
|
|
||||||
switch (rq.op) {
|
|
||||||
case ">":
|
|
||||||
match = v.gt(th);
|
|
||||||
break;
|
|
||||||
case ">=":
|
|
||||||
match = v.gte(th);
|
|
||||||
break;
|
|
||||||
case "<":
|
|
||||||
match = v.lt(th);
|
|
||||||
break;
|
|
||||||
case "<=":
|
|
||||||
match = v.lte(th);
|
|
||||||
break;
|
|
||||||
case "=":
|
|
||||||
match = v.eq(th);
|
|
||||||
break;
|
|
||||||
case "~":
|
|
||||||
match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (match) {
|
|
||||||
if (!lines) lines = doc.text.split("\n");
|
|
||||||
hits.push({
|
|
||||||
docId: doc.id,
|
|
||||||
docTitle: doc.title,
|
|
||||||
line: i + 1,
|
|
||||||
text: lines[i] ?? "",
|
|
||||||
result: r.text ?? undefined,
|
|
||||||
});
|
|
||||||
if (hits.length >= 200) return hits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
const ql = q.toLowerCase();
|
|
||||||
const hits: SearchHit[] = [];
|
|
||||||
for (const doc of docs) {
|
|
||||||
const lines = doc.text.split("\n");
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (lines[i].toLowerCase().includes(ql)) {
|
|
||||||
hits.push({
|
|
||||||
docId: doc.id,
|
|
||||||
docTitle: doc.title,
|
|
||||||
line: i + 1,
|
|
||||||
text: lines[i],
|
|
||||||
});
|
|
||||||
if (hits.length >= 200) return hits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hits;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initSearch(deps: SearchDeps): SearchController {
|
|
||||||
const overlay = $("#search-overlay");
|
|
||||||
const input = $<HTMLInputElement>("#search-input");
|
|
||||||
const resultsEl = $("#search-results");
|
|
||||||
|
|
||||||
function close(): void {
|
|
||||||
overlay.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(query: string, hits: SearchHit[]): void {
|
|
||||||
resultsEl.replaceChildren();
|
|
||||||
if (query.trim() && hits.length === 0) {
|
|
||||||
const empty = document.createElement("div");
|
|
||||||
empty.className = "search-empty";
|
|
||||||
empty.textContent = deps.t("searchEmpty");
|
|
||||||
resultsEl.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const hit of hits) {
|
|
||||||
const item = document.createElement("div");
|
|
||||||
item.className = "search-item";
|
|
||||||
const docEl = document.createElement("div");
|
|
||||||
docEl.className = "doc";
|
|
||||||
docEl.textContent = hit.docTitle;
|
|
||||||
const lineEl = document.createElement("div");
|
|
||||||
lineEl.className = "line";
|
|
||||||
lineEl.textContent = hit.result
|
|
||||||
? `${hit.text} = ${hit.result}`
|
|
||||||
: hit.text;
|
|
||||||
item.append(docEl, lineEl);
|
|
||||||
item.addEventListener("click", () => {
|
|
||||||
deps.onOpen(hit.docId, hit.line);
|
|
||||||
close();
|
|
||||||
});
|
|
||||||
frag.appendChild(item);
|
|
||||||
}
|
|
||||||
resultsEl.appendChild(frag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// a numeric query re-evaluates every open sheet — debounce so fast typing
|
|
||||||
// doesn't recompute the whole workspace on every keystroke
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
input.addEventListener("input", () => {
|
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
|
||||||
const q = input.value;
|
|
||||||
searchTimer = setTimeout(() => render(q, searchAllSheets(deps, q)), 150);
|
|
||||||
});
|
|
||||||
input.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
close();
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
resultsEl.querySelector<HTMLElement>(".search-item")?.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
overlay.addEventListener("mousedown", (e) => {
|
|
||||||
if (e.target === overlay) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
open(): void {
|
|
||||||
overlay.classList.remove("hidden");
|
|
||||||
input.value = "";
|
|
||||||
render("", []);
|
|
||||||
input.focus();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
||||||
import { buildSparkline } from "./sparkline";
|
|
||||||
|
|
||||||
describe("buildSparkline", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("document", {
|
|
||||||
createElementNS: vi.fn((ns, tag) => {
|
|
||||||
const attributes: Record<string, string> = {};
|
|
||||||
const classes: string[] = [];
|
|
||||||
const children: any[] = [];
|
|
||||||
const style: Record<string, string> = {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
tagName: tag,
|
|
||||||
namespaceURI: ns,
|
|
||||||
setAttribute: vi.fn((key, val) => {
|
|
||||||
attributes[key] = val;
|
|
||||||
}),
|
|
||||||
getAttribute: vi.fn((key) => attributes[key]),
|
|
||||||
classList: {
|
|
||||||
add: vi.fn((cls) => {
|
|
||||||
classes.push(cls);
|
|
||||||
}),
|
|
||||||
contains: vi.fn((cls) => classes.includes(cls)),
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
appendChild: vi.fn((child) => {
|
|
||||||
children.push(child);
|
|
||||||
}),
|
|
||||||
_getAttributes: () => attributes,
|
|
||||||
_getClasses: () => classes,
|
|
||||||
_getChildren: () => children,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a base SVG element with correct attributes and style", () => {
|
|
||||||
const pts = [10, 20, 30];
|
|
||||||
const svg = buildSparkline(pts, 150) as any;
|
|
||||||
|
|
||||||
expect(document.createElementNS).toHaveBeenCalledWith("http://www.w3.org/2000/svg", "svg");
|
|
||||||
expect(svg.tagName).toBe("svg");
|
|
||||||
expect(svg._getAttributes()).toMatchObject({
|
|
||||||
width: "80",
|
|
||||||
height: "20",
|
|
||||||
viewBox: "0 0 80 20",
|
|
||||||
});
|
|
||||||
expect(svg._getClasses()).toContain("result-sparkline");
|
|
||||||
expect(svg.style.top).toBe("150px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends a straight line when all points are equal (min === max)", () => {
|
|
||||||
const pts = [10, 10, 10];
|
|
||||||
const svg = buildSparkline(pts, 0) as any;
|
|
||||||
|
|
||||||
const children = svg._getChildren();
|
|
||||||
expect(children.length).toBe(1);
|
|
||||||
|
|
||||||
const line = children[0];
|
|
||||||
expect(line.tagName).toBe("line");
|
|
||||||
expect(line._getAttributes()).toMatchObject({
|
|
||||||
x1: "2",
|
|
||||||
y1: "10",
|
|
||||||
x2: "78",
|
|
||||||
y2: "10",
|
|
||||||
stroke: "var(--result)",
|
|
||||||
"stroke-width": "1.5",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends a polyline and a circle for varying points", () => {
|
|
||||||
const pts = [10, 20, 30]; // min=10, max=30
|
|
||||||
const svg = buildSparkline(pts, 0) as any;
|
|
||||||
|
|
||||||
const children = svg._getChildren();
|
|
||||||
expect(children.length).toBe(2);
|
|
||||||
|
|
||||||
const polyline = children[0];
|
|
||||||
expect(polyline.tagName).toBe("polyline");
|
|
||||||
expect(polyline._getAttributes()).toMatchObject({
|
|
||||||
fill: "none",
|
|
||||||
stroke: "var(--result)",
|
|
||||||
"stroke-width": "1.5",
|
|
||||||
"stroke-linecap": "round",
|
|
||||||
"stroke-linejoin": "round",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check points calculation
|
|
||||||
const pointsStr = polyline._getAttributes().points;
|
|
||||||
expect(typeof pointsStr).toBe("string");
|
|
||||||
const points = pointsStr.split(" ");
|
|
||||||
expect(points.length).toBe(3);
|
|
||||||
|
|
||||||
// First point should be at x=2 (pad), y=18 (H-pad because value is min)
|
|
||||||
expect(points[0]).toBe("2.0,18.0");
|
|
||||||
// Middle point should be at x=40 (pad + W/2), y=10 (pad + H/2 because value is mid)
|
|
||||||
expect(points[1]).toBe("40.0,10.0");
|
|
||||||
// Last point should be at x=78 (W-pad), y=2 (pad because value is max)
|
|
||||||
expect(points[2]).toBe("78.0,2.0");
|
|
||||||
|
|
||||||
const circle = children[1];
|
|
||||||
expect(circle.tagName).toBe("circle");
|
|
||||||
expect(circle._getAttributes()).toMatchObject({
|
|
||||||
cx: "78.0",
|
|
||||||
cy: "2.0",
|
|
||||||
r: "2",
|
|
||||||
fill: "var(--result)",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
export function buildSparkline(pts: number[], top: number): SVGSVGElement {
|
|
||||||
const NS = "http://www.w3.org/2000/svg";
|
|
||||||
const W = 80, H = 20, pad = 2;
|
|
||||||
const svg = document.createElementNS(NS, "svg") as SVGSVGElement;
|
|
||||||
svg.setAttribute("width", String(W));
|
|
||||||
svg.setAttribute("height", String(H));
|
|
||||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
|
||||||
svg.classList.add("result-sparkline");
|
|
||||||
svg.style.top = `${top}px`;
|
|
||||||
const min = Math.min(...pts), max = Math.max(...pts);
|
|
||||||
if (min === max) {
|
|
||||||
const line = document.createElementNS(NS, "line");
|
|
||||||
line.setAttribute("x1", String(pad));
|
|
||||||
line.setAttribute("y1", String(H / 2));
|
|
||||||
line.setAttribute("x2", String(W - pad));
|
|
||||||
line.setAttribute("y2", String(H / 2));
|
|
||||||
line.setAttribute("stroke", "var(--result)");
|
|
||||||
line.setAttribute("stroke-width", "1.5");
|
|
||||||
svg.appendChild(line);
|
|
||||||
} else {
|
|
||||||
const coords = pts.map((v, i) => {
|
|
||||||
const x = pad + (i / (pts.length - 1)) * (W - 2 * pad);
|
|
||||||
const y = pad + (1 - (v - min) / (max - min)) * (H - 2 * pad);
|
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
||||||
}).join(" ");
|
|
||||||
const poly = document.createElementNS(NS, "polyline");
|
|
||||||
poly.setAttribute("points", coords);
|
|
||||||
poly.setAttribute("fill", "none");
|
|
||||||
poly.setAttribute("stroke", "var(--result)");
|
|
||||||
poly.setAttribute("stroke-width", "1.5");
|
|
||||||
poly.setAttribute("stroke-linecap", "round");
|
|
||||||
poly.setAttribute("stroke-linejoin", "round");
|
|
||||||
svg.appendChild(poly);
|
|
||||||
const last = pts[pts.length - 1];
|
|
||||||
const cx = (W - pad).toFixed(1);
|
|
||||||
const cy = (pad + (1 - (last - min) / (max - min)) * (H - 2 * pad)).toFixed(1);
|
|
||||||
const dot = document.createElementNS(NS, "circle");
|
|
||||||
dot.setAttribute("cx", cx);
|
|
||||||
dot.setAttribute("cy", cy);
|
|
||||||
dot.setAttribute("r", "2");
|
|
||||||
dot.setAttribute("fill", "var(--result)");
|
|
||||||
svg.appendChild(dot);
|
|
||||||
}
|
|
||||||
return svg;
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { checkForUpdate } from './updater';
|
|
||||||
import * as storage from './storage';
|
|
||||||
import type { Update } from '@tauri-apps/plugin-updater';
|
|
||||||
|
|
||||||
vi.mock('./storage', () => ({
|
|
||||||
isTauri: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@tauri-apps/plugin-updater', () => ({
|
|
||||||
check: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@tauri-apps/api/app', () => ({
|
|
||||||
getVersion: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@tauri-apps/plugin-process', () => ({
|
|
||||||
relaunch: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('checkForUpdate', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
vi.spyOn(logger, 'info').mockImplementation(() => {});
|
|
||||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if not in Tauri', async () => {
|
|
||||||
vi.mocked(storage.isTauri).mockReturnValue(false);
|
|
||||||
const result = await checkForUpdate();
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return "error" if dynamic import or check throws', async () => {
|
|
||||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
|
||||||
vi.mocked(check).mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
|
||||||
expect(result).toBe('error');
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if no update is available', async () => {
|
|
||||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
|
||||||
const { getVersion } = await import('@tauri-apps/api/app');
|
|
||||||
|
|
||||||
vi.mocked(check).mockResolvedValue(null);
|
|
||||||
vi.mocked(getVersion).mockResolvedValue('1.0.0');
|
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(logger.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an update object if an update is available', async () => {
|
|
||||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
|
||||||
const { check } = await import('@tauri-apps/plugin-updater');
|
|
||||||
|
|
||||||
const mockUpdate = {
|
|
||||||
version: '1.1.0',
|
|
||||||
currentVersion: '1.0.0',
|
|
||||||
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
|
|
||||||
} as unknown as Update;
|
|
||||||
vi.mocked(check).mockResolvedValue(mockUpdate);
|
|
||||||
|
|
||||||
const result = await checkForUpdate();
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
if (result !== 'error' && result !== null) {
|
|
||||||
expect(result.version).toBe('1.1.0');
|
|
||||||
|
|
||||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
|
||||||
await result.install();
|
|
||||||
expect(mockUpdate.downloadAndInstall).toHaveBeenCalled();
|
|
||||||
expect(relaunch).toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +1,17 @@
|
||||||
import { logger } from "./logger";
|
|
||||||
import { isTauri } from "./storage";
|
import { isTauri } from "./storage";
|
||||||
|
|
||||||
interface AvailableUpdate {
|
export interface AvailableUpdate {
|
||||||
version: string;
|
version: string;
|
||||||
install(): Promise<void>;
|
install(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** null if no update, offline, or running outside Tauri (`vite dev`). */
|
||||||
* null = no update available (checked successfully); "error" = the check
|
export async function checkForUpdate(): Promise<AvailableUpdate | null> {
|
||||||
* itself failed (offline, updater/signature error); running outside Tauri
|
|
||||||
* (`vite dev`) also reports null.
|
|
||||||
*/
|
|
||||||
export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"> {
|
|
||||||
if (!isTauri()) return null;
|
if (!isTauri()) return null;
|
||||||
try {
|
try {
|
||||||
const { check } = await import("@tauri-apps/plugin-updater");
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (!update) {
|
if (!update) return null;
|
||||||
// visible in devtools console — the fastest way to tell "already on
|
|
||||||
// the latest published release" apart from "the check silently failed"
|
|
||||||
const { getVersion } = await import("@tauri-apps/api/app");
|
|
||||||
logger.info(`no update: running ${await getVersion()}, that's the latest published release`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
logger.info(`update available: ${update.currentVersion} -> ${update.version}`);
|
|
||||||
return {
|
return {
|
||||||
version: update.version,
|
version: update.version,
|
||||||
async install() {
|
async install() {
|
||||||
|
|
@ -32,8 +20,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
|
||||||
await relaunch();
|
await relaunch();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch {
|
||||||
logger.warn("update check failed", e);
|
return null;
|
||||||
return "error";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { SumEngine } from "./engine";
|
|
||||||
import { Workspace, SheetSource } from "./workspace";
|
|
||||||
|
|
||||||
function makeWorkspace(initial: Record<string, { title: string; text: string }>) {
|
|
||||||
const engine = new SumEngine();
|
|
||||||
const store = new Map(Object.entries(initial).map(([id, v]) => [id, { id, ...v }]));
|
|
||||||
const sheets = (): SheetSource[] => [...store.values()];
|
|
||||||
const ws = new Workspace(engine, sheets);
|
|
||||||
return { engine, store, ws };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Workspace exports", () => {
|
|
||||||
it("exposes assigned variables, total and last through @Sheet.key", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
budget: { title: "Budget", text: "rent = $500\nfood = $200" },
|
|
||||||
});
|
|
||||||
const r = ws.evaluateSheet("dash", "@Budget.rent\n@Budget.total\n@Budget.last");
|
|
||||||
expect(r[0].text).toBe("$500");
|
|
||||||
expect(r[1].text).toBe("$700");
|
|
||||||
expect(r[2].text).toBe("$200");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caches a sheet's exports until invalidated", () => {
|
|
||||||
const { ws, store } = makeWorkspace({
|
|
||||||
budget: { title: "Budget", text: "rent = $500" },
|
|
||||||
});
|
|
||||||
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500");
|
|
||||||
store.get("budget")!.text = "rent = $900";
|
|
||||||
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$500");
|
|
||||||
ws.invalidate("budget");
|
|
||||||
expect(ws.evaluateSheet("dash", "@Budget.rent")[0].text).toBe("$900");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("evaluates a sheet lazily — only when it's actually referenced", () => {
|
|
||||||
const { engine, ws } = makeWorkspace({
|
|
||||||
never: { title: "Never", text: "1 + 1" },
|
|
||||||
});
|
|
||||||
const spy = vi.spyOn(engine, "evaluateDocument");
|
|
||||||
ws.evaluateSheet("dash", "hello");
|
|
||||||
expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(false);
|
|
||||||
ws.evaluateSheet("dash", "@Never.total");
|
|
||||||
expect(spy.mock.calls.some(([text]) => text === "1 + 1")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Workspace error handling", () => {
|
|
||||||
it("cleans up the resolving set if evaluation throws an unexpected error", () => {
|
|
||||||
const { ws, engine } = makeWorkspace({
|
|
||||||
a: { title: "A", text: "1 + 1" },
|
|
||||||
});
|
|
||||||
vi.spyOn(engine, "evaluateDocument").mockImplementation(() => {
|
|
||||||
throw new Error("mocked crash");
|
|
||||||
});
|
|
||||||
expect(() => ws.evaluateSheet("a", "1 + 1")).toThrowError("mocked crash");
|
|
||||||
expect((ws as any).resolving.has("a")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Workspace invalidation", () => {
|
|
||||||
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
|
|
||||||
const { ws, store } = makeWorkspace({
|
|
||||||
a: { title: "A", text: "x = 10" },
|
|
||||||
b: { title: "B", text: "z = @A.x" },
|
|
||||||
c: { title: "C", text: "y = 5" },
|
|
||||||
});
|
|
||||||
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10");
|
|
||||||
expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5");
|
|
||||||
|
|
||||||
store.get("a")!.text = "x = 99";
|
|
||||||
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("10");
|
|
||||||
|
|
||||||
ws.invalidate("a");
|
|
||||||
expect(ws.evaluateSheet("dash", "@B.z")[0].text).toBe("99");
|
|
||||||
expect(ws.evaluateSheet("dash", "@C.y")[0].text).toBe("5");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Workspace cycle detection", () => {
|
|
||||||
it("a true two-sheet cycle terminates instead of looping forever", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
a: { title: "A", text: "z = @B.total" },
|
|
||||||
b: { title: "B", text: "z = @A.total" },
|
|
||||||
});
|
|
||||||
const r = ws.evaluateSheet("dash", "@A.z");
|
|
||||||
expect(r[0].value).toBeNull();
|
|
||||||
expect(r[0].error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a sheet referencing itself surfaces a circular reference immediately", () => {
|
|
||||||
const { ws } = makeWorkspace({ self: { title: "Self", text: "" } });
|
|
||||||
const text = "a = 5\nb = @Self.a";
|
|
||||||
expect(() => ws.evaluateSheet("self", text)).not.toThrow();
|
|
||||||
const r = ws.evaluateSheet("self", text);
|
|
||||||
expect(r[1].value).toBeNull();
|
|
||||||
expect(r[1].error).toBe("circular reference");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not cache exports for a sheet whose own line hit circular reference", () => {
|
|
||||||
// Resolving @A.z evaluates A -> resolves @B.total -> evaluates B -> resolves
|
|
||||||
// @A.total -> A is already on the resolving stack, so B's own line
|
|
||||||
// (`z = @A.total`) gets the literal error "circular reference". A's own line
|
|
||||||
// only sees the secondary error 'sheet "B" has no total', so A gets cached —
|
|
||||||
// but B must not, since its LineResult[] literally contains "circular reference".
|
|
||||||
const { engine, ws } = makeWorkspace({
|
|
||||||
a: { title: "A", text: "z = @B.total" },
|
|
||||||
b: { title: "B", text: "z = @A.total" },
|
|
||||||
});
|
|
||||||
const first = ws.evaluateSheet("dash", "@A.z");
|
|
||||||
expect(first[0].error).toBe('no variable "z" in "A"');
|
|
||||||
|
|
||||||
const spy = vi.spyOn(engine, "evaluateDocument");
|
|
||||||
const second = ws.evaluateSheet("dash2", "@B.z");
|
|
||||||
expect(second[0].error).toBe('no variable "z" in "B"');
|
|
||||||
// A is cached from the first pass, so this second call re-evaluates B only.
|
|
||||||
// If exportsFor(B) had cached the poisoned result from the first pass instead,
|
|
||||||
// B's text would never be re-evaluated here.
|
|
||||||
expect(spy.mock.calls.some(([text]) => text === "z = @A.total")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Workspace.renameSheet", () => {
|
|
||||||
it("rewrites bare-form references to the new title", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
budget: { title: "Budget", text: "rent = $500" },
|
|
||||||
dash: { title: "Dashboard", text: "@Budget.rent + 1" },
|
|
||||||
});
|
|
||||||
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([{ id: "dash", text: "@Money.rent + 1" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to bracket form when the new title needs it", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
budget: { title: "Budget", text: "rent = $500" },
|
|
||||||
dash: { title: "Dashboard", text: "@Budget.rent" },
|
|
||||||
});
|
|
||||||
expect(ws.renameSheet("budget", "Budget", "Monthly Budget")).toEqual([
|
|
||||||
{ id: "dash", text: "@[Monthly Budget].rent" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rewrites existing bracket-form references too", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
trip: { title: "Trip to Lisbon", text: "food = 25" },
|
|
||||||
dash: { title: "Dashboard", text: "@[Trip to Lisbon].food" },
|
|
||||||
});
|
|
||||||
expect(ws.renameSheet("trip", "Trip to Lisbon", "Lisbon")).toEqual([{ id: "dash", text: "@Lisbon.food" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never rewrites the renamed sheet's own text", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
budget: { title: "Budget", text: "a = 1\nb = @Budget.a" },
|
|
||||||
});
|
|
||||||
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unrelated sheets untouched", () => {
|
|
||||||
const { ws } = makeWorkspace({
|
|
||||||
budget: { title: "Budget", text: "rent = $500" },
|
|
||||||
other: { title: "Other", text: "just text, no refs" },
|
|
||||||
});
|
|
||||||
expect(ws.renameSheet("budget", "Budget", "Money")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
174
src/workspace.ts
174
src/workspace.ts
|
|
@ -1,174 +0,0 @@
|
||||||
// Owns the cross-sheet dependency graph: which sheets a `@Sheet.key`
|
|
||||||
// reference can see, cached exports, cycle detection, and rename rewrites.
|
|
||||||
// The engine has no concept of "other sheets" — that lives entirely here.
|
|
||||||
import type { SumEngine, LineResult } from "./engine";
|
|
||||||
import type { Value } from "./engine/types";
|
|
||||||
import type { XRefResolution } from "./engine/evaluator";
|
|
||||||
|
|
||||||
export interface SheetSource {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SheetExports {
|
|
||||||
vars: Map<string, Value>;
|
|
||||||
total: Value | null;
|
|
||||||
last: Value | null;
|
|
||||||
results: LineResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const XREF_SCAN_RE = /@(?:\[([^\]]+)\]|([\p{L}_][\p{L}\d_]*))\./gu;
|
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Workspace {
|
|
||||||
private cache = new Map<string, SheetExports>();
|
|
||||||
private resolving: Set<string> = new Set();
|
|
||||||
private parsedRefsCache = new Map<string, { text: string; refs: string[] }>();
|
|
||||||
private titleCache: Map<string, SheetSource> | null = null;
|
|
||||||
|
|
||||||
constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {}
|
|
||||||
|
|
||||||
private doEvaluate(sheetId: string, text: string): LineResult[] {
|
|
||||||
this.resolving.add(sheetId);
|
|
||||||
try {
|
|
||||||
return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key));
|
|
||||||
} finally {
|
|
||||||
this.resolving.delete(sheetId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Evaluate a sheet as the user's active/open document — refs resolve live. */
|
|
||||||
evaluateSheet(sheetId: string, text: string): LineResult[] {
|
|
||||||
return this.doEvaluate(sheetId, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCachedResults(sheet: SheetSource): LineResult[] {
|
|
||||||
return this.exportsFor(sheet).results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop cached exports for a sheet and everything that transitively depends on it. */
|
|
||||||
invalidate(sheetId: string): void {
|
|
||||||
this.titleCache = null;
|
|
||||||
const dirty = new Set<string>([sheetId]);
|
|
||||||
const sheetsList = this.sheets();
|
|
||||||
let changed = true;
|
|
||||||
while (changed) {
|
|
||||||
changed = false;
|
|
||||||
for (const s of sheetsList) {
|
|
||||||
if (dirty.has(s.id)) continue;
|
|
||||||
for (const title of this.referencedTitles(s.text, s.id)) {
|
|
||||||
const target = this.findSheetByTitle(title);
|
|
||||||
if (target && dirty.has(target.id)) {
|
|
||||||
dirty.add(s.id);
|
|
||||||
changed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of dirty) this.cache.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidateAll(): void {
|
|
||||||
this.titleCache = null;
|
|
||||||
this.cache.clear();
|
|
||||||
this.parsedRefsCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicit rename: rewrite `@OldTitle.` / `@[Old Title].` to the new name
|
|
||||||
* in every *other* sheet. Returns only the sheets whose text changed.
|
|
||||||
*/
|
|
||||||
renameSheet(renamedId: string, oldTitle: string, newTitle: string): { id: string; text: string }[] {
|
|
||||||
const oldEsc = escapeRegExp(oldTitle.trim());
|
|
||||||
const bareRe = new RegExp(`@${oldEsc}\\.`, "gu");
|
|
||||||
const bracketRe = new RegExp(`@\\[\\s*${oldEsc}\\s*\\]\\.`, "gu");
|
|
||||||
const trimmedNew = newTitle.trim();
|
|
||||||
const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`;
|
|
||||||
const out: { id: string; text: string }[] = [];
|
|
||||||
for (const s of this.sheets()) {
|
|
||||||
if (s.id === renamedId) continue;
|
|
||||||
const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement);
|
|
||||||
if (rewritten !== s.text) out.push({ id: s.id, text: rewritten });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private referencedTitles(text: string, id?: string): string[] {
|
|
||||||
if (id) {
|
|
||||||
const cached = this.parsedRefsCache.get(id);
|
|
||||||
if (cached && cached.text === text) return cached.refs;
|
|
||||||
}
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim());
|
|
||||||
if (id) {
|
|
||||||
this.parsedRefsCache.set(id, { text, refs: out });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private findSheetByTitle(title: string): SheetSource | undefined {
|
|
||||||
const needle = title.trim().toLowerCase();
|
|
||||||
if (this.titleCache === null) {
|
|
||||||
this.titleCache = new Map();
|
|
||||||
for (const s of this.sheets()) {
|
|
||||||
const key = s.title.trim().toLowerCase();
|
|
||||||
if (!this.titleCache.has(key)) {
|
|
||||||
this.titleCache.set(key, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.titleCache.get(needle);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveXRef(sheetTitle: string, key: string): XRefResolution {
|
|
||||||
const target = this.findSheetByTitle(sheetTitle);
|
|
||||||
if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` };
|
|
||||||
if (this.resolving.has(target.id)) {
|
|
||||||
return { ok: false, reason: "circular reference" };
|
|
||||||
}
|
|
||||||
const exports = this.exportsFor(target);
|
|
||||||
if (key === "total") {
|
|
||||||
return exports.total
|
|
||||||
? { ok: true, value: exports.total }
|
|
||||||
: { ok: false, reason: `sheet "${sheetTitle}" has no total` };
|
|
||||||
}
|
|
||||||
if (key === "last") {
|
|
||||||
return exports.last
|
|
||||||
? { ok: true, value: exports.last }
|
|
||||||
: { ok: false, reason: `sheet "${sheetTitle}" has no result` };
|
|
||||||
}
|
|
||||||
const v = exports.vars.get(key);
|
|
||||||
if (!v) return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` };
|
|
||||||
return { ok: true, value: v };
|
|
||||||
}
|
|
||||||
|
|
||||||
private exportsFor(sheet: SheetSource): SheetExports {
|
|
||||||
const cached = this.cache.get(sheet.id);
|
|
||||||
if (cached) return cached;
|
|
||||||
const results = this.doEvaluate(sheet.id, sheet.text);
|
|
||||||
const vars = new Map<string, Value>();
|
|
||||||
for (const r of results) {
|
|
||||||
if (r.assign && r.value) vars.set(r.assign, r.value);
|
|
||||||
}
|
|
||||||
let last: Value | null = null;
|
|
||||||
for (let i = results.length - 1; i >= 0; i--) {
|
|
||||||
if (results[i].kind === "normal" && results[i].value) {
|
|
||||||
last = results[i].value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const exports: SheetExports = { vars, total: this.engine.totalValueOf(results), last, results };
|
|
||||||
// A cycle-interrupted pass produces incomplete/wrong exports (dropped
|
|
||||||
// assignments, missing total) — don't let it poison the cache. Re-evaluate
|
|
||||||
// fresh on every query until the cycle is actually broken by an edit.
|
|
||||||
if (!results.some((r) => r.error === "circular reference")) {
|
|
||||||
this.cache.set(sheet.id, exports);
|
|
||||||
}
|
|
||||||
return exports;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue