Compare commits

..

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

54 changed files with 972 additions and 3840 deletions

View file

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

View file

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

View file

@ -23,16 +23,13 @@ src/
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) search.ts cross-sheet search overlay (text + result-value queries)
export.ts renders the sheet + results to a PNG (clipboard/file) 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 in a QuickJS/WASM sandbox, bridged
to the engine's numi.* API 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 + Yahoo Finance v8 chart)
@ -218,40 +215,6 @@ 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
@ -279,9 +242,7 @@ recursing forever.
flipping it while the app is running takes effect immediately); the manual flipping it while the app is running takes effect immediately); the manual
button ignores the toggle and always works. 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
@ -303,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
## Tests ## Tests
`npm test` runs 373 vitest cases across 21 files: `src/engine/*.test.ts` covers every `npm test` runs 259 vitest cases: `src/engine/*.test.ts` covers every
expression class, both languages, deterministic injected rates, goal seek, expression class, both languages, deterministic injected rates, goal seek,
historical rates (injected), date-format literals/display (dedicated engines historical rates (injected), date-format literals/display (dedicated engines
with an explicit `dateFormat`, since the default "system" format depends on with an explicit `dateFormat`, since the default "system" format depends on

View file

@ -24,16 +24,13 @@ src/
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
search.ts оверлей поиска по всем листам (текст + запросы по значению) search.ts оверлей поиска по всем листам (текст + запросы по значению)
export.ts рендерит лист + результаты в PNG (буфер обмена/файл) 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.* движка 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 + Yahoo Finance v8 chart)
@ -220,40 +217,6 @@ 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` при запуске; если
@ -282,9 +245,7 @@ src-tauri/
тике интервала, так что переключение прямо во время работы срабатывает тике интервала, так что переключение прямо во время работы срабатывает
сразу); ручная кнопка тумблер игнорирует и работает всегда. сразу); ручная кнопка тумблер игнорирует и работает всегда.
- Эндпоинт обновлений — `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 — в репозитории нет постоянной копии). Потеря этого ключа
@ -307,7 +268,7 @@ src-tauri/
## Тесты ## Тесты
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все `npm test` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все
классы выражений, оба языка, детерминированные инжектированные курсы, поиск классы выражений, оба языка, детерминированные инжектированные курсы, поиск
неизвестного, исторические курсы (инжектированные), литералы и отображение неизвестного, исторические курсы (инжектированные), литералы и отображение
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по форматов дат (отдельные движки с явным `dateFormat` — формат «system» по

View file

@ -29,29 +29,16 @@ 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
`libayatana-appindicator3` for the tray icon.
- **macOS 10.13** or newer.
- Internet connection is only needed for live rates and market data — everything - 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).
@ -169,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash ```bash
npm install npm install
npm test # engine tests (373 cases) npm test # engine tests (259 cases)
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 +165,19 @@ To build from source you need:
- [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on - [Node.js](https://nodejs.org/) 22.6 or newer (`npm run icons` relies on
Node's built-in TypeScript support) Node's built-in TypeScript support)
- [Rust](https://rustup.rs/), stable - [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- On Windows: the MSVC toolchain plus - [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
with the "Desktop development with C++" workload (the MSVC linker) with the "Desktop development with C++" workload (the MSVC linker)
- On Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` and a C toolchain
- On macOS: Xcode Command Line Tools
- See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page - See the [Tauri prerequisites](https://tauri.app/start/prerequisites/) page
if anything refuses to compile if anything refuses to compile
`npm run tauri build` produces whatever the host platform bundles — NSIS on
Windows, deb/rpm/AppImage on Linux, .app/.dmg on macOS. Cross-compiling
between the three is not supported; the release workflow builds each on its
own runner.
Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6. Stack: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Credits ## Credits
Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev — Inspired by the excellent [Numi](https://numi.app) by Dmitry Nikolaev —
if you're on macOS, buy it. Summarum is an independent project, built from if you're on macOS, buy it. Summarum is an independent project, built from
scratch for Windows, Linux and macOS. scratch for Windows.
## License ## License

View file

@ -27,28 +27,14 @@ AAPL в USD → $213.55
## Установка ## Установка
Скачай сборку под свою систему из [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** или новее.
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн - Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
(курсы берутся из кэша или встроенного снимка). (курсы берутся из кэша или встроенного снимка).
@ -165,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
```bash ```bash
npm install npm install
npm test # тесты движка (373 кейса) npm test # тесты движка (259 кейсов)
npm run tauri dev # запустить приложение npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик npm run tauri build # собрать установщик
``` ```
@ -173,25 +159,17 @@ npm run tauri build # собрать установщик
Для сборки нужны: Для сборки нужны:
- [Node.js](https://nodejs.org/) 22.6 или новее - [Node.js](https://nodejs.org/) 22.6 или новее
- [Rust](https://rustup.rs/), stable - [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- На Windows: toolchain MSVC и - [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
с компонентом «Desktop development with C++» (нужен линковщик MSVC) с компонентом «Desktop development with C++» (нужен линковщик MSVC)
- На Linux: `libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`,
`librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `patchelf` и компилятор C
- На macOS: Xcode Command Line Tools
- Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/) - Если что-то не собирается — см. [Tauri prerequisites](https://tauri.app/start/prerequisites/)
`npm run tauri build` собирает то, что умеет хост-платформа: NSIS на Windows,
deb/rpm/AppImage на Linux, .app/.dmg на macOS. Кросс-компиляция между ними не
поддерживается — релизный workflow собирает каждую платформу на своём раннере.
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6. Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Благодарность ## Благодарность
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева — Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows, Linux и macOS. если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows.
## Лицензия ## Лицензия

View file

@ -30,7 +30,7 @@
</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 id="market-info" title="" style="display: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" aria-label="Export">⤓</button>

10
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "summarum", "name": "summarum",
"version": "1.2.9", "version": "1.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "summarum", "name": "summarum",
"version": "1.2.9", "version": "1.2.3",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.0", "@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0", "@codemirror/commands": "^6.8.0",
@ -1642,9 +1642,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View file

@ -1,8 +1,8 @@
{ {
"name": "summarum", "name": "summarum",
"private": true, "private": true,
"version": "1.2.9", "version": "1.2.3",
"description": "Summarum — natural language calculator notebook", "description": "Summarum — natural language calculator notebook for Windows",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

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

20
src-tauri/Cargo.lock generated
View file

@ -3038,7 +3038,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 +3057,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 +3102,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,7 +3724,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "summarum" name = "summarum"
version = "1.2.9" version = "1.2.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"futures", "futures",
@ -4927,19 +4924,6 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"

View file

@ -1,7 +1,7 @@
[package] [package]
name = "summarum" name = "summarum"
version = "1.2.9" version = "1.2.3"
description = "Summarum — natural language calculator notebook" description = "Summarum — natural language calculator notebook for Windows"
edition = "2021" edition = "2021"
[build-dependencies] [build-dependencies]
@ -18,7 +18,7 @@ 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" futures = "0.3.32"

Binary file not shown.

View file

@ -1,29 +1,19 @@
// Summarum — Tauri shell: tray, hide-to-tray window, app-data storage, // Summarum — Tauri shell: tray, hide-to-tray window, app-data storage,
// currency rates fetching with an on-disk cache, extension script loading. // currency rates fetching with an on-disk cache, extension script loading.
#![cfg_attr( #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{Menu, MenuItem}; use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, DragDropEvent, Emitter, Manager, WindowEvent}; use tauri::{AppHandle, Emitter, Manager, WindowEvent};
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
const RATES_TTL_SECS: u64 = 3600; const RATES_TTL_SECS: u64 = 3600;
const SAMPLE_JS: &str = include_str!("../extension-sample.js"); const SAMPLE_JS: &str = include_str!("../extension-sample.js");
/// Rust owns this file: it records the data folder the user committed to via
/// the native picker. `save_file`/`load_file` refuse the name so the frontend
/// cannot forge its own authorization.
const DATA_DIR_FILE: &str = "data-dir.txt";
const MAX_SHEET_BYTES: u64 = 1_000_000;
fn data_dir(app: &AppHandle) -> PathBuf { fn data_dir(app: &AppHandle) -> PathBuf {
let dir = app.path().app_data_dir().expect("no app data dir"); let dir = app.path().app_data_dir().expect("no app data dir");
@ -31,124 +21,17 @@ fn data_dir(app: &AppHandle) -> PathBuf {
dir dir
} }
/// Which folder the frontend may point file operations at.
///
/// Every `dir` argument arrives over IPC, where a buggy extension or
/// compromised webview content can name any path on disk, so a path counts
/// only when the user proved intent for it: `picked` is the folder just chosen
/// in the native picker, `active` is the folder in use — plus the one being
/// migrated away from, so a migration that fails partway leaves the folder the
/// frontend is still pointing at usable.
#[derive(Default)]
struct DataDirGate {
picked: Mutex<Option<PathBuf>>,
active: Mutex<Vec<PathBuf>>,
}
fn canon_or_raw(p: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in p.components() {
match component {
std::path::Component::ParentDir => {
let can_pop = matches!(
normalized.components().next_back(),
Some(std::path::Component::Normal(_))
);
if can_pop {
normalized.pop();
} else if !normalized.has_root() {
normalized.push(component);
}
}
std::path::Component::CurDir => {}
_ => normalized.push(component),
}
}
normalized
}
/// Exact match against an authorized folder, compared canonicalized so a
/// symlink or ".." spelling cannot stand in for a different path. A subfolder
/// of an authorized folder is not itself authorized.
fn path_allowed(candidate: &Path, allowed: &[PathBuf]) -> bool {
let c = canon_or_raw(candidate);
allowed.iter().any(|a| canon_or_raw(a) == c)
}
/// The custom folder recorded in the app's own settings.json, if any. Only
/// read once, to seed the gate on the first run after it was introduced —
/// settings.json is frontend-writable and is not a trust anchor afterwards.
fn settings_data_dir(app: &AppHandle) -> Option<PathBuf> {
let raw = fs::read_to_string(data_dir(app).join("settings.json")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
let d = parsed.get("dataDir")?.as_str()?.trim();
(!d.is_empty()).then(|| PathBuf::from(d))
}
fn seed_data_dir_gate(app: &AppHandle) {
let marker = data_dir(app).join(DATA_DIR_FILE);
let active = match fs::read_to_string(&marker) {
Ok(raw) => {
let t = raw.trim();
(!t.is_empty()).then(|| PathBuf::from(t))
}
Err(_) => {
// upgrading over an install that predates the gate: adopt the
// folder already configured, then stop consulting settings.json
let adopted = settings_data_dir(app);
let contents = adopted
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
write_atomic(&marker, &contents).ok();
adopted
}
};
*app.state::<DataDirGate>().active.lock().unwrap() = active.into_iter().collect();
}
/// Let the session use `dir` without yet declaring it the folder to come back
/// to on the next launch — the previous folder stays usable meanwhile.
fn authorize_data_dir(app: &AppHandle, dir: &Path) {
let gate = app.state::<DataDirGate>();
let mut active = gate.active.lock().unwrap();
active.retain(|p| p != dir);
active.insert(0, dir.to_path_buf());
active.truncate(2);
}
/// Record the folder the migration actually landed in. Only reached once every
/// file is in place, so a half-finished migration never becomes the folder the
/// app boots into.
fn commit_data_dir(app: &AppHandle, dir: &Path) -> Result<(), String> {
write_atomic(
&data_dir(app).join(DATA_DIR_FILE),
dir.to_string_lossy().as_ref(),
)?;
authorize_data_dir(app, dir);
Ok(())
}
/// Documents live either in the default app-data dir or in a user-chosen /// Documents live either in the default app-data dir or in a user-chosen
/// folder (settings stay in app-data — the path itself is stored there). /// folder (settings stay in app-data — the path itself is stored there).
/// Anything other than the default has to clear `DataDirGate` first. fn docs_dir(app: &AppHandle, dir: &Option<String>) -> PathBuf {
fn docs_dir(app: &AppHandle, dir: &Option<String>) -> Result<PathBuf, String> { match dir {
let requested = dir.as_deref().map(str::trim).unwrap_or(""); Some(d) if !d.trim().is_empty() => {
if requested.is_empty() { let p = PathBuf::from(d);
return Ok(data_dir(app)); fs::create_dir_all(&p).ok();
p
} }
let candidate = PathBuf::from(requested); _ => data_dir(app),
let gate = app.state::<DataDirGate>();
let allowed: Vec<PathBuf> = {
let mut allowed = gate.active.lock().unwrap().clone();
allowed.extend(gate.picked.lock().unwrap().clone());
allowed
};
if !path_allowed(&candidate, &allowed) {
return Err("data folder was not chosen by the user".into());
} }
fs::create_dir_all(&candidate).ok();
Ok(candidate)
} }
fn safe_name(name: &str) -> bool { fn safe_name(name: &str) -> bool {
@ -191,7 +74,13 @@ fn write_image_file(app: AppHandle, data_base64: String) -> Result<bool, String>
.blocking_save_file() .blocking_save_file()
{ {
let path = p.into_path().map_err(|e| e.to_string())?; let path = p.into_path().map_err(|e| e.to_string())?;
write_atomic_bytes(&path, &base64_decode(&data_base64)?)?; use std::io::Write;
let bytes = base64_decode(&data_base64)?;
let tmp = path.with_extension("tmp");
std::fs::File::create(&tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| e.to_string())?;
fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(true) Ok(true)
} else { } else {
Ok(false) Ok(false)
@ -216,9 +105,6 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
for chunk in bytes.chunks(4) { for chunk in bytes.chunks(4) {
if chunk.len() < 2 {
return Err("invalid base64 padding".into());
}
let b0 = chunk[0]; let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0); let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0); let b2 = *chunk.get(2).unwrap_or(&0);
@ -234,40 +120,16 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
Ok(out) Ok(out)
} }
static TMP_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
/// write via a temp file + rename so a crash mid-write cannot corrupt the target /// write via a temp file + rename so a crash mid-write cannot corrupt the target
fn write_atomic_bytes(path: &Path, contents: &[u8]) -> Result<(), String> { fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let count = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let tmp = path.with_extension("tmp");
let tmp = path.with_extension(format!("tmp.{}", count));
fs::write(&tmp, contents).map_err(|e| e.to_string())?; fs::write(&tmp, contents).map_err(|e| e.to_string())?;
fs::rename(&tmp, path).map_err(|e| e.to_string()) fs::rename(&tmp, path).map_err(|e| e.to_string())
} }
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
write_atomic_bytes(path, contents.as_bytes())
}
async fn read_file_async(path: PathBuf) -> Option<String> {
tauri::async_runtime::spawn_blocking(move || fs::read_to_string(path).ok())
.await
.ok()
.flatten()
}
async fn write_atomic_async(path: PathBuf, contents: String) {
let _ = tauri::async_runtime::spawn_blocking(move || write_atomic(&path, &contents)).await;
}
/// settings.json and friends; DATA_DIR_FILE is Rust's own authorization record
/// and must not be forgeable from the frontend.
fn writable_name(name: &str) -> bool {
safe_name(name) && !name.eq_ignore_ascii_case(DATA_DIR_FILE)
}
#[tauri::command] #[tauri::command]
fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), String> { fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), String> {
if !writable_name(&name) { if !safe_name(&name) {
return Err("bad file name".into()); return Err("bad file name".into());
} }
write_atomic(&data_dir(&app).join(name), &contents) write_atomic(&data_dir(&app).join(name), &contents)
@ -275,7 +137,7 @@ fn save_file(app: AppHandle, name: String, contents: String) -> Result<(), Strin
#[tauri::command] #[tauri::command]
fn load_file(app: AppHandle, name: String) -> Result<Option<String>, String> { fn load_file(app: AppHandle, name: String) -> Result<Option<String>, String> {
if !writable_name(&name) { if !safe_name(&name) {
return Err("bad file name".into()); return Err("bad file name".into());
} }
let path = data_dir(&app).join(name); let path = data_dir(&app).join(name);
@ -333,46 +195,26 @@ fn now_secs() -> u64 {
.as_secs() .as_secs()
} }
async fn read_rates_cache(app: &AppHandle) -> Option<RatesCache> { fn read_rates_cache(app: &AppHandle) -> Option<RatesCache> {
let raw = read_file_async(data_dir(app).join("rates.json")).await?; let raw = fs::read_to_string(data_dir(app).join("rates.json")).ok()?;
serde_json::from_str(&raw).ok() serde_json::from_str(&raw).ok()
} }
const RATES_RESPONSE_LIMIT: usize = 5 * 1024 * 1024;
/// Reads the body as a stream and aborts as soon as it exceeds `limit`,
/// instead of buffering an unbounded body in memory before checking its size.
async fn read_body_capped(resp: reqwest::Response, limit: usize) -> Result<Vec<u8>, String> {
use futures::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| e.to_string())?;
buf.extend_from_slice(&chunk);
if buf.len() > limit {
return Err("response too large".into());
}
}
Ok(buf)
}
async fn fetch_rates_online() -> Result<RatesPayload, String> { async fn fetch_rates_online() -> Result<RatesPayload, String> {
let client = HTTP_CLIENT.get_or_init(|| { let client = reqwest::Client::builder()
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))
.build() .build()
.unwrap_or_default() .map_err(|e| e.to_string())?;
});
let resp = client let body: serde_json::Value = client
.get("https://open.er-api.com/v6/latest/USD") .get("https://open.er-api.com/v6/latest/USD")
.send() .send()
.await .await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?;
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if body["result"] != "success" { if body["result"] != "success" {
return Err("rates api error".into()); return Err("rates api error".into());
} }
@ -385,8 +227,7 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
ids.join(",") ids.join(",")
); );
if let Ok(resp) = client.get(&url).send().await { if let Ok(resp) = client.get(&url).send().await {
if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await { if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
for (code, id) in CRYPTO { for (code, id) in CRYPTO {
if let Some(price) = json[id]["usd"].as_f64() { if let Some(price) = json[id]["usd"].as_f64() {
if price > 0.0 { if price > 0.0 {
@ -396,7 +237,6 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
} }
} }
} }
}
Ok(RatesPayload { Ok(RatesPayload {
date: body["time_last_update_utc"] date: body["time_last_update_utc"]
@ -411,7 +251,7 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
#[tauri::command] #[tauri::command]
async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String> { async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String> {
if !force { if !force {
if let Some(cache) = read_rates_cache(&app).await { if let Some(cache) = read_rates_cache(&app) {
if now_secs().saturating_sub(cache.fetched_at) < RATES_TTL_SECS { if now_secs().saturating_sub(cache.fetched_at) < RATES_TTL_SECS {
let mut payload = cache.payload; let mut payload = cache.payload;
payload.fetched_at = cache.fetched_at; payload.fetched_at = cache.fetched_at;
@ -426,12 +266,11 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String
payload: payload.clone(), payload: payload.clone(),
}; };
if let Ok(raw) = serde_json::to_string(&cache) { if let Ok(raw) = serde_json::to_string(&cache) {
write_atomic_async(data_dir(&app).join("rates.json"), raw).await; write_atomic(&data_dir(&app).join("rates.json"), &raw).ok();
} }
Ok(payload) Ok(payload)
} }
Err(e) => read_rates_cache(&app) Err(e) => read_rates_cache(&app)
.await
.map(|c| { .map(|c| {
let mut payload = c.payload; let mut payload = c.payload;
payload.fetched_at = c.fetched_at; payload.fetched_at = c.fetched_at;
@ -459,7 +298,7 @@ async fn fetch_market_data(
let cache_path = data_dir(&app).join("market.json"); let cache_path = data_dir(&app).join("market.json");
// try cache first // try cache first
if let Some(raw) = read_file_async(cache_path.clone()).await { if let Ok(raw) = fs::read_to_string(&cache_path) {
if let Ok(cache) = serde_json::from_str::<MarketCache>(&raw) { if let Ok(cache) = serde_json::from_str::<MarketCache>(&raw) {
if now_secs().saturating_sub(cache.fetched_at) < MARKET_TTL_SECS { if now_secs().saturating_sub(cache.fetched_at) < MARKET_TTL_SECS {
let syms: std::collections::HashSet<&str> = let syms: std::collections::HashSet<&str> =
@ -523,7 +362,7 @@ async fn fetch_market_data(
prices: prices.clone(), prices: prices.clone(),
}; };
if let Ok(raw) = serde_json::to_string(&cache) { if let Ok(raw) = serde_json::to_string(&cache) {
write_atomic_async(cache_path, raw).await; write_atomic(&cache_path, &raw).ok();
} }
} }
@ -541,25 +380,6 @@ fn valid_date_str(date: &str) -> bool {
&& parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
} }
static HTTP_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
/// frankfurter quotes everything against the `from=USD` base, so USD itself is
/// never in the response and has to be anchored by hand.
fn parse_rate_map(
raw_rates: &serde_json::Map<String, serde_json::Value>,
) -> std::collections::HashMap<String, f64> {
let mut rates = std::collections::HashMap::new();
rates.insert("USD".to_string(), 1.0_f64);
for (code, val) in raw_rates {
if let Some(v) = val.as_f64() {
if v > 0.0 {
rates.insert(code.to_uppercase(), v);
}
}
}
rates
}
#[tauri::command] #[tauri::command]
async fn fetch_historical_rates( async fn fetch_historical_rates(
app: AppHandle, app: AppHandle,
@ -569,111 +389,47 @@ async fn fetch_historical_rates(
return Err("invalid date format".into()); return Err("invalid date format".into());
} }
let cache_path = data_dir(&app).join(format!("rates-{}.json", date)); let cache_path = data_dir(&app).join(format!("rates-{}.json", date));
if let Some(raw) = read_file_async(cache_path.clone()).await { if let Ok(raw) = fs::read_to_string(&cache_path) {
if let Ok(cached) = serde_json::from_str::<std::collections::HashMap<String, f64>>(&raw) { if let Ok(cached) = serde_json::from_str::<std::collections::HashMap<String, f64>>(&raw) {
return Ok(cached); return Ok(cached);
} }
} }
let client = HTTP_CLIENT.get_or_init(|| { let client = reqwest::Client::builder()
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))
.build() .build()
.unwrap_or_default() .map_err(|e| e.to_string())?;
});
// api.frankfurter.app 301-redirects here now, and the old domain is // api.frankfurter.app 301-redirects here now, and the old domain is
// flaky over reqwest/rustls-tls — go straight to the stable host // flaky over reqwest/rustls-tls — go straight to the stable host
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date); let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; let body: serde_json::Value = client
.get(&url)
let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?; .send()
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; .await
let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?); .map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let raw_rates = body["rates"].as_object().ok_or("no rates in response")?;
let mut rates = std::collections::HashMap::new();
rates.insert("USD".to_string(), 1.0_f64);
for (code, val) in raw_rates {
if let Some(v) = val.as_f64() {
if v > 0.0 {
rates.insert(code.to_uppercase(), v);
}
}
}
if let Ok(serialized) = serde_json::to_string(&rates) { if let Ok(serialized) = serde_json::to_string(&rates) {
write_atomic_async(cache_path, serialized).await; write_atomic(&cache_path, &serialized).ok();
} }
Ok(rates) Ok(rates)
} }
#[tauri::command]
async fn fetch_historical_rates_batch(
app: AppHandle,
dates: Vec<String>,
) -> Result<std::collections::HashMap<String, std::collections::HashMap<String, f64>>, String> {
let mut results = std::collections::HashMap::new();
let mut missing_dates = Vec::new();
for date in &dates {
if !valid_date_str(date) {
continue;
}
let cache_path = data_dir(&app).join(format!("rates-{}.json", date));
if let Some(raw) = read_file_async(cache_path).await {
if let Ok(cached) = serde_json::from_str::<std::collections::HashMap<String, f64>>(&raw)
{
results.insert(date.clone(), cached);
continue;
}
}
missing_dates.push(date.clone());
}
if missing_dates.is_empty() {
return Ok(results);
}
let client = HTTP_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_default()
});
let mut futures = Vec::new();
for date in missing_dates {
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
let client_clone = client.clone();
let app_clone = app.clone();
futures.push(async move {
let mut rate_map = None;
if let Ok(resp) = client_clone.get(&url).send().await {
if let Ok(bytes) = read_body_capped(resp, RATES_RESPONSE_LIMIT).await {
if let Ok(body) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Some(rates_obj) = body["rates"].as_object() {
let daily_rates = parse_rate_map(rates_obj);
let cache_path =
data_dir(&app_clone).join(format!("rates-{}.json", date));
if let Ok(serialized) = serde_json::to_string(&daily_rates) {
write_atomic_async(cache_path, serialized).await;
}
rate_map = Some((date, daily_rates));
}
}
}
}
rate_map
});
}
use futures::stream::StreamExt;
let fetched_results = futures::stream::iter(futures)
.buffer_unordered(5)
.collect::<Vec<_>>()
.await;
for (date, rates) in fetched_results.into_iter().flatten() {
results.insert(date, rates);
}
Ok(results)
}
// ---------- documents in the (possibly custom) data folder // ---------- documents in the (possibly custom) data folder
#[tauri::command] #[tauri::command]
fn load_documents(app: AppHandle, dir: Option<String>) -> Result<Option<String>, String> { fn load_documents(app: AppHandle, dir: Option<String>) -> Result<Option<String>, String> {
let path = docs_dir(&app, &dir)?.join("documents.json"); let path = docs_dir(&app, &dir).join("documents.json");
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
} }
@ -684,7 +440,7 @@ fn load_documents(app: AppHandle, dir: Option<String>) -> Result<Option<String>,
#[tauri::command] #[tauri::command]
fn save_documents(app: AppHandle, dir: Option<String>, contents: String) -> Result<(), String> { fn save_documents(app: AppHandle, dir: Option<String>, contents: String) -> Result<(), String> {
write_atomic(&docs_dir(&app, &dir)?.join("documents.json"), &contents) write_atomic(&docs_dir(&app, &dir).join("documents.json"), &contents)
} }
// ---------- backups // ---------- backups
@ -695,7 +451,7 @@ const SNAPSHOT_KEEP: usize = 14;
/// before the app writes anything, so it captures yesterday's state. /// before the app writes anything, so it captures yesterday's state.
#[tauri::command] #[tauri::command]
fn run_backups(app: AppHandle, dir: Option<String>, retention_days: u32) -> Result<(), String> { fn run_backups(app: AppHandle, dir: Option<String>, retention_days: u32) -> Result<(), String> {
let base = docs_dir(&app, &dir)?; let base = docs_dir(&app, &dir);
let docs = base.join("documents.json"); let docs = base.join("documents.json");
let bdir = base.join("backups"); let bdir = base.join("backups");
fs::create_dir_all(bdir.join("deleted")).map_err(|e| e.to_string())?; fs::create_dir_all(bdir.join("deleted")).map_err(|e| e.to_string())?;
@ -750,7 +506,7 @@ fn backup_deleted_sheet(
title: String, title: String,
contents: String, contents: String,
) -> Result<(), String> { ) -> Result<(), String> {
let bin = docs_dir(&app, &dir)?.join("backups").join("deleted"); let bin = docs_dir(&app, &dir).join("backups").join("deleted");
fs::create_dir_all(&bin).map_err(|e| e.to_string())?; fs::create_dir_all(&bin).map_err(|e| e.to_string())?;
let safe: String = title let safe: String = title
.chars() .chars()
@ -767,12 +523,12 @@ fn backup_deleted_sheet(
safe, safe,
chrono::Local::now().format("%Y-%m-%d-%H%M%S") chrono::Local::now().format("%Y-%m-%d-%H%M%S")
); );
write_atomic(&bin.join(name), &contents) fs::write(bin.join(name), contents).map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
fn open_backups_folder(app: AppHandle, dir: Option<String>) -> Result<(), String> { fn open_backups_folder(app: AppHandle, dir: Option<String>) -> Result<(), String> {
let bdir = docs_dir(&app, &dir)?.join("backups"); let bdir = docs_dir(&app, &dir).join("backups");
fs::create_dir_all(&bdir).ok(); fs::create_dir_all(&bdir).ok();
app.opener() app.opener()
.open_path(bdir.to_string_lossy(), None::<&str>) .open_path(bdir.to_string_lossy(), None::<&str>)
@ -781,22 +537,9 @@ fn open_backups_folder(app: AppHandle, dir: Option<String>) -> Result<(), String
// ---------- data folder migration // ---------- data folder migration
/// Gated like every other `dir` argument: without it this is a probe for the
/// existence of an arbitrary path anywhere on disk.
#[tauri::command] #[tauri::command]
fn data_dir_has_documents(app: AppHandle, dir: String) -> Result<bool, String> { fn data_dir_has_documents(dir: String) -> bool {
Ok(docs_dir(&app, &Some(dir))?.join("documents.json").exists()) PathBuf::from(dir).join("documents.json").exists()
}
#[tauri::command]
fn pick_data_dir(app: AppHandle) -> Result<Option<String>, String> {
let Some(picked) = app.dialog().file().blocking_pick_folder() else {
return Ok(None);
};
let path = picked.into_path().map_err(|e| e.to_string())?;
let canon = fs::canonicalize(&path).map_err(|e| e.to_string())?;
*app.state::<DataDirGate>().picked.lock().unwrap() = Some(canon);
Ok(Some(path.to_string_lossy().into_owned()))
} }
/// strategy: "move" (copy mine, delete originals), "overwrite" (same, target /// strategy: "move" (copy mine, delete originals), "overwrite" (same, target
@ -808,24 +551,17 @@ fn migrate_data_dir(
new_dir: String, new_dir: String,
strategy: String, strategy: String,
) -> Result<(), String> { ) -> Result<(), String> {
// `old_dir` is exactly as untrusted as `new_dir`: on "move" this function let old = docs_dir(&app, &old_dir);
// deletes documents.json and every file under backups/ beneath it, so it
// has to clear the gate before anything else happens.
let old = docs_dir(&app, &old_dir)?;
let new = PathBuf::from(&new_dir); let new = PathBuf::from(&new_dir);
fs::create_dir_all(&new).map_err(|e| e.to_string())?;
// canonicalize: "C:\Data" and "c:\data" are the same folder on Windows // canonicalize: "C:\Data" and "c:\data" are the same folder on Windows
let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?;
let gate = app.state::<DataDirGate>();
{
let picked = gate.picked.lock().unwrap();
if picked.as_deref() != Some(canon_new.as_path()) {
return Err("target folder was not chosen by the user".into());
}
}
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone()); let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
if canon_old == canon_new || strategy == "use_existing" { let canon_new = fs::canonicalize(&new).unwrap_or_else(|_| new.clone());
gate.picked.lock().unwrap().take(); if canon_old == canon_new {
return commit_data_dir(&app, &canon_new); return Ok(());
}
if strategy == "use_existing" {
return Ok(());
} }
let old_docs = old.join("documents.json"); let old_docs = old.join("documents.json");
if old_docs.exists() { if old_docs.exists() {
@ -847,43 +583,20 @@ fn migrate_data_dir(
} }
} }
} }
gate.picked.lock().unwrap().take();
// originals removed only after everything is actually copied // originals removed only after everything is actually copied
if old_docs.exists() { if old_docs.exists() {
fs::remove_file(&old_docs).ok(); fs::remove_file(&old_docs).ok();
} }
// one level at a time, files only — a recursive delete of anything named
// "backups" is too big a hammer even for a gated path.
// run_backups/backup_deleted_sheet only ever write flat files here.
if all_copied { if all_copied {
for sub in ["backups", "backups/deleted"] { fs::remove_dir_all(old.join("backups")).ok();
if let Ok(entries) = fs::read_dir(old.join(sub)) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() {
fs::remove_file(&p).ok();
} }
} Ok(())
}
}
}
commit_data_dir(&app, &canon_new)
} }
/// Paths the OS actually handed us in a window drop event. The frontend can /// Used by drag&drop: read a calculation/text file from an absolute path.
/// name any path over IPC, so this is the only proof a read was user-initiated.
#[derive(Default)]
struct DroppedFiles(Mutex<HashSet<PathBuf>>);
/// Used by drag&drop: read a calculation/text file dropped onto the window.
#[tauri::command] #[tauri::command]
fn read_text_file(app: AppHandle, path: String) -> Result<String, String> { fn read_text_file(path: String) -> Result<String, String> {
// compare canonicalized, so a symlink or ".." spelling of an allowed path let p = std::path::PathBuf::from(&path);
// cannot stand in for a different file
let p = fs::canonicalize(&path).map_err(|e| e.to_string())?;
if !app.state::<DroppedFiles>().0.lock().unwrap().contains(&p) {
return Err("file was not dropped onto the window".into());
}
let ext = p let ext = p
.extension() .extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
@ -893,7 +606,7 @@ fn read_text_file(app: AppHandle, path: String) -> Result<String, String> {
return Err("unsupported file type".into()); return Err("unsupported file type".into());
} }
let meta = fs::metadata(&p).map_err(|e| e.to_string())?; let meta = fs::metadata(&p).map_err(|e| e.to_string())?;
if meta.len() > MAX_SHEET_BYTES { if meta.len() > 1_000_000 {
return Err("file too large".into()); return Err("file too large".into());
} }
fs::read_to_string(p).map_err(|e| e.to_string()) fs::read_to_string(p).map_err(|e| e.to_string())
@ -942,22 +655,15 @@ fn is_sheet_path(path: &str) -> bool {
lower.ends_with(".numi") || lower.ends_with(".sum") lower.ends_with(".numi") || lower.ends_with(".sum")
} }
/// A sheet handed to us by the OS (file association, second launch): same
/// extension whitelist and size cap `read_text_file` applies to drag&drop.
fn read_sheet_file(path: &Path) -> Option<String> {
if !is_sheet_path(&path.to_string_lossy()) {
return None;
}
if fs::metadata(path).ok()?.len() > MAX_SHEET_BYTES {
return None;
}
fs::read_to_string(path).ok()
}
/// Contents of a .numi/.sum file passed on the command line (file association). /// Contents of a .numi/.sum file passed on the command line (file association).
#[tauri::command] #[tauri::command]
fn get_launch_file() -> Option<String> { fn get_launch_file() -> Option<String> {
read_sheet_file(Path::new(&std::env::args().nth(1)?)) let arg = std::env::args().nth(1)?;
if is_sheet_path(&arg) {
fs::read_to_string(arg).ok()
} else {
None
}
} }
#[tauri::command] #[tauri::command]
@ -1011,7 +717,7 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(path) = args.iter().skip(1).find(|a| is_sheet_path(a)) { if let Some(path) = args.iter().skip(1).find(|a| is_sheet_path(a)) {
if let Some(content) = read_sheet_file(Path::new(path)) { if let Ok(content) = fs::read_to_string(path) {
app.emit("open-file", content).ok(); app.emit("open-file", content).ok();
} }
} }
@ -1029,8 +735,6 @@ fn main() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.manage(DroppedFiles::default())
.manage(DataDirGate::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
save_file, save_file,
load_file, load_file,
@ -1040,11 +744,9 @@ fn main() {
backup_deleted_sheet, backup_deleted_sheet,
open_backups_folder, open_backups_folder,
data_dir_has_documents, data_dir_has_documents,
pick_data_dir,
migrate_data_dir, migrate_data_dir,
fetch_rates, fetch_rates,
fetch_historical_rates, fetch_historical_rates,
fetch_historical_rates_batch,
fetch_market_data, fetch_market_data,
load_extensions, load_extensions,
open_extensions_folder, open_extensions_folder,
@ -1057,9 +759,6 @@ fn main() {
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
// before the webview can ask for anything, so a frontend-written
// settings.json cannot influence an already-seeded gate
seed_data_dir_gate(&handle);
let show = MenuItem::with_id(app, "show", "Show / Hide", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "Show / Hide", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit Summarum", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit Summarum", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?; let menu = Menu::with_items(app, &[&show, &quit])?;
@ -1084,25 +783,15 @@ fn main() {
} }
}) })
.build(app)?; .build(app)?;
let _ = handle;
Ok(()) Ok(())
}) })
.on_window_event(|window, event| match event { .on_window_event(|window, event| {
// close hides to tray // close hides to tray
WindowEvent::CloseRequested { api, .. } => { if let WindowEvent::CloseRequested { api, .. } = event {
window.hide().ok(); window.hide().ok();
api.prevent_close(); api.prevent_close();
} }
// runs before the webview gets tauri://drag-drop (that emit only
// queues a script eval), so the paths are allowed by the time the
// frontend can ask for them
WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) => {
let allowed: HashSet<PathBuf> = paths
.iter()
.filter_map(|p| fs::canonicalize(p).ok())
.collect();
*window.state::<DroppedFiles>().0.lock().unwrap() = allowed;
}
_ => {}
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Summarum"); .expect("error while running Summarum");
@ -1138,137 +827,6 @@ mod tests {
assert!(safe_name("rates.json")); assert!(safe_name("rates.json"));
} }
// writable_name — the frontend must not be able to forge the gate's record
#[test]
fn writable_name_rejects_the_data_dir_marker() {
assert!(!writable_name(DATA_DIR_FILE));
assert!(!writable_name("DATA-DIR.TXT"));
}
#[test]
fn writable_name_accepts_ordinary_app_files() {
assert!(writable_name("settings.json"));
assert!(writable_name("documents.json"));
}
fn temp_subdir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("summarum-test-{}-{}", tag, nanos));
fs::create_dir_all(&dir).unwrap();
dir
}
// path_allowed — the IPC trust boundary for data-folder paths
#[test]
fn path_allowed_rejects_when_nothing_is_authorized() {
let dir = temp_subdir("gate-empty");
assert!(!path_allowed(&dir, &[]));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn path_allowed_accepts_the_authorized_dir() {
let dir = temp_subdir("gate-ok");
assert!(path_allowed(&dir, std::slice::from_ref(&dir)));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn path_allowed_accepts_a_different_spelling_of_the_same_dir() {
let dir = temp_subdir("gate-spelling");
fs::create_dir_all(dir.join("sub")).unwrap();
let detour = dir.join("sub").join("..");
assert!(path_allowed(&detour, std::slice::from_ref(&dir)));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn path_allowed_rejects_an_unrelated_dir() {
let allowed = temp_subdir("gate-allowed");
let attacker = temp_subdir("gate-attacker");
assert!(!path_allowed(&attacker, std::slice::from_ref(&allowed)));
fs::remove_dir_all(&allowed).ok();
fs::remove_dir_all(&attacker).ok();
}
#[test]
fn path_allowed_rejects_a_subdir_of_an_authorized_dir() {
let allowed = temp_subdir("gate-parent");
let child = allowed.join("child");
fs::create_dir_all(&child).unwrap();
assert!(!path_allowed(&child, std::slice::from_ref(&allowed)));
fs::remove_dir_all(&allowed).ok();
}
#[test]
fn path_allowed_rejects_a_missing_dir_that_is_not_authorized() {
let allowed = temp_subdir("gate-missing");
let ghost = allowed
.parent()
.unwrap()
.join("summarum-test-ghost-does-not-exist");
assert!(!path_allowed(&ghost, std::slice::from_ref(&allowed)));
fs::remove_dir_all(&allowed).ok();
}
// parse_rate_map
#[test]
fn parse_rate_map_always_anchors_usd() {
let obj = serde_json::json!({});
let map = parse_rate_map(obj.as_object().unwrap());
assert_eq!(map.get("USD"), Some(&1.0));
}
#[test]
fn parse_rate_map_uppercases_codes() {
let obj = serde_json::json!({ "eur": 0.9, "Gbp": 0.8 });
let map = parse_rate_map(obj.as_object().unwrap());
assert_eq!(map.get("EUR"), Some(&0.9));
assert_eq!(map.get("GBP"), Some(&0.8));
}
#[test]
fn parse_rate_map_skips_nonpositive_and_nonnumeric() {
let obj =
serde_json::json!({ "EUR": 0.9, "AAA": 0.0, "BBB": -1.5, "CCC": "oops", "DDD": null });
let map = parse_rate_map(obj.as_object().unwrap());
assert_eq!(map.get("EUR"), Some(&0.9));
assert!(!map.contains_key("AAA"));
assert!(!map.contains_key("BBB"));
assert!(!map.contains_key("CCC"));
assert!(!map.contains_key("DDD"));
}
// read_sheet_file — size cap shared with read_text_file
#[test]
fn read_sheet_file_reads_a_small_sheet() {
let dir = temp_subdir("sheet-small");
let path = dir.join("calc.numi");
fs::write(&path, "2 + 2").unwrap();
assert_eq!(read_sheet_file(&path).as_deref(), Some("2 + 2"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_sheet_file_rejects_an_oversized_sheet() {
let dir = temp_subdir("sheet-big");
let path = dir.join("calc.numi");
fs::write(&path, "x".repeat((MAX_SHEET_BYTES + 1) as usize)).unwrap();
assert!(read_sheet_file(&path).is_none());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_sheet_file_rejects_a_foreign_extension() {
let dir = temp_subdir("sheet-ext");
let path = dir.join("payload.js");
fs::write(&path, "alert(1)").unwrap();
assert!(read_sheet_file(&path).is_none());
fs::remove_dir_all(&dir).ok();
}
// is_sheet_path // is_sheet_path
#[test] #[test]
fn is_sheet_path_matches_extensions() { fn is_sheet_path_matches_extensions() {

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Summarum", "productName": "Summarum",
"version": "1.2.9", "version": "1.2.3",
"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' 'wasm-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 https://query1.finance.yahoo.com https://query2.finance.yahoo.com https://api.frankfurter.dev"
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.png", "icons/icon.png",
"icons/icon.ico", "icons/icon.ico"
"icons/icon.icns"
], ],
"fileAssociations": [ "fileAssociations": [
{ {
@ -44,7 +44,13 @@
"description": "Summarum calculation sheet", "description": "Summarum calculation sheet",
"role": "Editor" "role": "Editor"
} }
] ],
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["English", "Russian"]
}
}
}, },
"plugins": { "plugins": {
"updater": { "updater": {

View file

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

View file

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

View file

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

View file

@ -13,12 +13,38 @@ describe("datetime", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("should detect dmy (e.g. en-GB)", () => { it("should detect dmy", () => {
expect(detectDateOrder("en-GB")).toBe("dmy"); const mockFormatToParts = vi.fn().mockReturnValue([
{ type: "day", value: "31" },
{ type: "literal", value: "/" },
{ type: "month", value: "12" },
{ type: "literal", value: "/" },
{ type: "year", value: "2000" },
]);
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
formatToParts: mockFormatToParts,
}) as any);
expect(detectDateOrder()).toBe("dmy");
mockDateTimeFormat.mockRestore();
}); });
it("should detect mdy (e.g. en-US)", () => { it("should detect mdy", () => {
expect(detectDateOrder("en-US")).toBe("mdy"); const mockFormatToParts = vi.fn().mockReturnValue([
{ type: "month", value: "12" },
{ type: "literal", value: "/" },
{ type: "day", value: "31" },
{ type: "literal", value: "/" },
{ type: "year", value: "2000" },
]);
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
formatToParts: mockFormatToParts,
}) as any);
expect(detectDateOrder()).toBe("mdy");
mockDateTimeFormat.mockRestore();
}); });
it("should default to dmy if neither is found", () => { it("should default to dmy if neither is found", () => {
@ -178,32 +204,5 @@ describe("datetime", () => {
expect(isCalendarUnit("minute", 1)).toBe(false); expect(isCalendarUnit("minute", 1)).toBe(false);
expect(isCalendarUnit("unit:meter", 1)).toBe(false); expect(isCalendarUnit("unit:meter", 1)).toBe(false);
}); });
it("should handle edge cases for amount", () => {
expect(isCalendarUnit("day", 0)).toBe(true);
expect(isCalendarUnit("day", Number.MAX_SAFE_INTEGER)).toBe(true);
expect(isCalendarUnit("day", Number.MIN_SAFE_INTEGER)).toBe(true);
expect(isCalendarUnit("day", NaN)).toBe(false);
expect(isCalendarUnit("day", Infinity)).toBe(false);
expect(isCalendarUnit("day", -Infinity)).toBe(false);
});
it("should handle edge cases for unitId string format", () => {
expect(isCalendarUnit("", 1)).toBe(false);
expect(isCalendarUnit(":", 1)).toBe(false);
// string.split(":") behavior:
expect(isCalendarUnit("time:month", 1)).toBe(true);
expect(isCalendarUnit(":day", 1)).toBe(true);
expect(isCalendarUnit("day:", 1)).toBe(false);
expect(isCalendarUnit("a:day:c", 1)).toBe(true);
});
it("should return false for case-sensitive mismatch", () => {
expect(isCalendarUnit("Day", 1)).toBe(false);
expect(isCalendarUnit("YEAR", 1)).toBe(false);
expect(isCalendarUnit("MONTH", 1)).toBe(false);
});
}); });
}); });

View file

@ -2,8 +2,8 @@
import { DateOrder } from "./types"; import { DateOrder } from "./types";
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */ /** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
export function detectDateOrder(locale?: string): DateOrder { export function detectDateOrder(): DateOrder {
const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" }) const parts = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "numeric", year: "numeric" })
.formatToParts(new Date(2000, 11, 31)); .formatToParts(new Date(2000, 11, 31));
for (const p of parts) { for (const p of parts) {
if (p.type === "day") return "dmy"; if (p.type === "day") return "dmy";
@ -105,13 +105,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 +134,6 @@ function lastDayOfMonth(d: Date): number {
} }
export function isCalendarUnit(unitId: string, amount: number): boolean { export function isCalendarUnit(unitId: string, amount: number): boolean {
const baseId = baseUnitId(unitId); const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount); return (baseId === "day" || baseId === "week" || baseId === "month" || baseId === "year") && Number.isInteger(amount);
} }

View file

@ -13,10 +13,6 @@ function calc(expr: string): string | null {
return r[r.length - 1].text; return r[r.length - 1].text;
} }
function evaluateText(doc: string): (string | null)[] {
return eng.evaluateDocument(doc).map((x) => x.text);
}
describe("edge cases", () => { describe("edge cases", () => {
it("russian mixed sentence", () => { it("russian mixed sentence", () => {
expect(calc("потратил 20$ и ещё 5$")).toBe("$25"); expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
@ -49,11 +45,11 @@ describe("edge cases", () => {
expect(calc("hello world")).toBe(null); expect(calc("hello world")).toBe(null);
}); });
it("sum stops at header", () => { it("sum stops at header", () => {
const r = evaluateText("5\n# section\n10\n20\nsum"); const r = eng.evaluateDocument("5\n# section\n10\n20\nsum").map((x) => x.text);
expect(r).toEqual(["5", null, "10", "20", "30"]); expect(r).toEqual(["5", null, "10", "20", "30"]);
}); });
it("variables with units survive math", () => { it("variables with units survive math", () => {
const r = evaluateText("rent = $500\nfood = $200\nrent + food"); const r = eng.evaluateDocument("rent = $500\nfood = $200\nrent + food").map((x) => x.text);
expect(r).toEqual(["$500", "$200", "$700"]); expect(r).toEqual(["$500", "$200", "$700"]);
}); });
it("big numbers with grouping", () => { it("big numbers with grouping", () => {
@ -149,7 +145,7 @@ describe("crypto", () => {
describe("regressions", () => { describe("regressions", () => {
it("a broken line does not kill the document (BigInt from NaN)", () => { it("a broken line does not kill the document (BigInt from NaN)", () => {
const r = evaluateText("2+2\nasin(5) & 3\n3+3"); const r = eng.evaluateDocument("2+2\nasin(5) & 3\n3+3").map((x) => x.text);
expect(r).toEqual(["4", null, "6"]); expect(r).toEqual(["4", null, "6"]);
}); });
it("non-finite results are suppressed", () => { it("non-finite results are suppressed", () => {
@ -182,8 +178,8 @@ describe("regressions", () => {
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
}); });
it("single-letter unit names can be variables", () => { it("single-letter unit names can be variables", () => {
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]); expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]); expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
}); });
it("tiny conversion results show significant digits, not 0", () => { it("tiny conversion results show significant digits, not 0", () => {
expect(calc("1 mm in km")).toBe("0.000001 km"); expect(calc("1 mm in km")).toBe("0.000001 km");
@ -203,7 +199,7 @@ describe("regressions", () => {
expect(calc("4 ^ 50%")).toBe("2"); expect(calc("4 ^ 50%")).toBe("2");
}); });
it("avg skips incompatible lines in the denominator", () => { it("avg skips incompatible lines in the denominator", () => {
expect(evaluateText("10 kg\n20 kg\n5 hours\navg")).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]); expect(eng.evaluateDocument("10 kg\n20 kg\n5 hours\navg").map((x) => x.text)).toEqual(["10 kg", "20 kg", "5 h", "15 kg"]);
}); });
it("calendar month add clamps to month end", () => { it("calendar month add clamps to month end", () => {
const doc = eng.evaluateDocument("today + 1 month"); const doc = eng.evaluateDocument("today + 1 month");
@ -286,44 +282,3 @@ describe("review fixes", () => {
}); });
}); });
}); });
describe("regression 2026-08-12", () => {
it("a deeply nested-parens line nulls out just that line, not the whole document", () => {
const deep = "(".repeat(50000) + "1" + ")".repeat(50000);
const r = evaluateText(`1+1\n${deep}\n2+2`);
expect(r[0]).toBe("2");
expect(r[1]).toBeNull();
expect(r[2]).toBe("4");
});
it("'5 min' means 5 minutes, not the min() aggregate", () => {
expect(calc("5 min")).toBe("5 min");
expect(calc("5 мин")).toBe("5 min");
});
it("standalone 'min'/'max' aggregate lines still work", () => {
const r = evaluateText("10\n20\n5\nmin");
expect(r).toEqual(["10", "20", "5", "5"]);
const r2 = evaluateText("10\n20\n5\nmax");
expect(r2).toEqual(["10", "20", "5", "20"]);
});
function unitIdOf(expr: string): string | null {
const v = eng.evaluateDocument(expr)[0].value;
return v?.kind === "quantity" ? v.unit?.id ?? null : null;
}
it("ambiguous currency aliases resolve to one deliberate currency; the other needs its full name", () => {
expect(unitIdOf("100 Manat")).toBe("AZN");
expect(unitIdOf("100 Turkmenistan Manat")).toBe("TMT");
expect(unitIdOf("100 манат")).toBe("AZN");
expect(unitIdOf("100 туркменский манат")).toBe("TMT");
expect(unitIdOf("100 FC")).toBe("CDF");
expect(unitIdOf("100 Comorian Franc")).toBe("KMF");
// "Som"/"Kwacha" go to the larger economy (UZS/ZMW); the smaller one needs its full name
expect(unitIdOf("100 Som")).toBe("UZS");
expect(unitIdOf("100 Kyrgyzstani Som")).toBe("KGS");
expect(unitIdOf("100 Kwacha")).toBe("ZMW");
expect(unitIdOf("100 Malawian Kwacha")).toBe("MWK");
expect(unitIdOf("100 квача")).toBe("ZMW");
expect(unitIdOf("100 малавийская квача")).toBe("MWK");
});
});

View file

@ -1,9 +1,8 @@
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 { qty } from "./types";
import type { XRefResolution, EvalCtx } from "./evaluator"; import type { XRefResolution } from "./evaluator";
import { evaluate } from "./evaluator"; import { formatValue } from "./index";
import { formatValue } from "./formatter";
let eng: SumEngine; let eng: SumEngine;
@ -218,7 +217,7 @@ describe("numeral systems", () => {
describe("variables and document context", () => { describe("variables and document context", () => {
it("sequence error recovery", () => { it("sequence error recovery", () => {
eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => { eng.addFunction({ id: "throw_err", phrases: "throw_err" }, () => {
throw new EvalError("test error"); throw new Error("test error");
}); });
expect(calc("throw_err(1) 2 hours")).toBe("2 h"); expect(calc("throw_err(1) 2 hours")).toBe("2 h");
}); });
@ -335,13 +334,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");
@ -431,24 +423,3 @@ describe("cross-sheet references", () => {
expect(r[0].assign).toBe("rent"); expect(r[0].assign).toBe("rent");
}); });
}); });
describe("evaluation errors", () => {
it("throws EvalError for unknown variable", () => {
const ctx = {
vars: new Map(),
reg: eng.reg,
historicalRates: {},
} as unknown as EvalCtx;
expect(() => evaluate({ k: "var", name: "unknown" }, ctx)).toThrowError(EvalError);
});
it("throws XRefError for unresolved xref", () => {
const ctx = {
vars: new Map(),
reg: eng.reg,
historicalRates: {},
resolveXRef: () => ({ ok: false, reason: "xref error" }),
} as unknown as EvalCtx;
expect(() => evaluate({ k: "xref", sheet: "Sheet", key: "key" }, ctx)).toThrowError(XRefError);
});
});

View file

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

View file

@ -1,14 +1,9 @@
// Walks the AST with the document context (variables, line results for // Walks the AST with the document context (variables, line results for
// sum/avg/prev) and produces unit-aware values. // sum/avg/prev) and produces unit-aware values.
import { Decimal, DEC_ZERO, DEC_ONE, EvalError, PI, Quantity, Unit, Value, XRefError, qty, pct } from "./types"; import { Decimal, EvalError, Quantity, Unit, Value, XRefError, 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) */
@ -30,11 +25,10 @@ export interface EvalCtx {
resolveXRef?: XRefResolver; resolveXRef?: XRefResolver;
} }
export type XRefResolution = export type XRefResolution = { ok: true; value: Value } | { ok: false; reason: string };
| { ok: true; value: Value }
| { ok: false; reason: string };
export type XRefResolver = (sheet: string, key: string) => XRefResolution; export type XRefResolver = (sheet: string, key: string) => XRefResolution;
const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
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 +36,71 @@ 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": case "datelit":
return { kind: "date", ms: node.ms, hasTime: false }; return { kind: "date", ms: node.ms, hasTime: false };
case "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;
}
case "unknown": {
const xv = ctx.vars.get("__x__");
if (!xv) throw new EvalError("? used outside goal seek");
return xv;
}
case "goalseek": case "goalseek":
return evalGoalSeek(node.lhs, node.rhs, ctx); 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 +108,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 +143,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 +176,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 +186,36 @@ 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 evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value): Value {
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
l: Value,
r: Value,
): Value {
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 as { kind: "percent"; value: Decimal }).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 +225,14 @@ 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( function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", a: Quantity, b: Quantity, ctx: EvalCtx): Value {
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
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;
@ -224,8 +247,7 @@ function evalQuantityArith(
if (u) return qty(baseProduct.div(u.ratio), u); if (u) return qty(baseProduct.div(u.ratio), u);
} }
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500 // same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
if (da === db) if (da === db) return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
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,28 +266,16 @@ 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( function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value, ctx: EvalCtx): Value {
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
l: Value,
r: Value,
ctx: EvalCtx,
): Value {
if (l.kind === "date" || r.kind === "date") { if (l.kind === "date" || r.kind === "date") {
return evalDateArith(op, l, r); return evalDateArith(op, l, r);
} }
@ -278,43 +288,24 @@ function evalBin(
} }
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 +313,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 +327,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,8 +349,7 @@ 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);
} }
@ -369,17 +358,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
if (target.onDate) { if (target.onDate) {
const dateRates = ctx.historicalRates?.get(target.onDate); const dateRates = ctx.historicalRates?.get(target.onDate);
const rate = dateRates?.get(target.code); const rate = dateRates?.get(target.code);
if (rate === undefined) if (rate === undefined) throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`);
throw new EvalError(
`no historical rate for ${target.code} on ${target.onDate}`,
);
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate)); unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
} else { } else {
unit = ctx.reg.makeCurrencyUnit(target.code); unit = ctx.reg.makeCurrencyUnit(target.code);
if (!unit) throw new EvalError(`no rate for ${target.code}`); if (!unit) throw new EvalError(`no rate for ${target.code}`);
} }
if (v.kind !== "quantity") if (v.kind !== "quantity") throw new EvalError("cannot convert to currency");
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 +383,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 +397,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 +410,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 +421,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;
@ -482,97 +443,70 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
const innerCtx: EvalCtx = { ...ctx, vars }; const innerCtx: EvalCtx = { ...ctx, vars };
const lv = evaluate(lhsNode, innerCtx); const lv = evaluate(lhsNode, innerCtx);
const rv = evaluate(rhsNode, innerCtx); const rv = evaluate(rhsNode, innerCtx);
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : DEC_ZERO; const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : new Decimal(0);
return toD(lv).minus(toD(rv)); return toD(lv).minus(toD(rv));
}; };
const safeEval = (x: Decimal): Decimal | null => { // Linear probe: f(0) and f(1) give slope → exact root for linear expressions
try { const f0 = evalF(new Decimal(0));
return evalF(x); const f1 = evalF(new Decimal(1));
} catch {
return null;
}
};
const f0 = safeEval(DEC_ZERO);
const f1 = safeEval(DEC_ONE);
if (f0 !== null && f1 !== null) {
const slope = f1.minus(f0); const slope = f1.minus(f0);
if (!slope.isZero()) { if (!slope.isZero()) {
const root = f0.neg().div(slope); const root = f0.neg().div(slope);
const fRoot = safeEval(root); if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root);
if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9")))
return qty(root);
}
} }
// Bisection fallback for nonlinear cases // Bisection fallback for nonlinear cases
let lo = new Decimal(-1e9); let lo = new Decimal(-1e9);
let hi = new Decimal(1e9); let hi = new Decimal(1e9);
let flo = safeEval(lo); let flo = evalF(lo);
let fhi = safeEval(hi); let fhi = evalF(hi);
if (flo.mul(fhi).gt(0)) throw new EvalError("no solution");
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++) { for (let i = 0; i < 100; i++) {
const mid = lo.plus(hi).div(2); const mid = lo.plus(hi).div(2);
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid); if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
const fm = safeEval(mid); const fm = evalF(mid);
if (fm === null) { if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; }
lo = mid.plus("1e-9"); else { lo = mid; flo = fm; }
continue;
}
if (flo!.mul(fm).lte(0)) {
hi = mid;
fhi = fm;
} else {
lo = mid;
flo = fm;
}
} }
return qty(lo.plus(hi).div(2)); return qty(lo.plus(hi).div(2));
} }
/** Lines whose unit shares the first line's dimension (mixed-dimension blocks skip the rest). */ const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
function filterSameDimension(block: Quantity[]): Quantity[] { count: (block) => qty(new Decimal(block.length)),
chart: (block) => {
const refDim = block[0].unit?.dimension ?? null; const refDim = block[0].unit?.dimension ?? null;
return block.filter((q) => (q.unit?.dimension ?? null) === refDim); const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
} if (compatible.length < 2) throw new EvalError("need at least 2 values for chart");
return {
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */ kind: "chart",
function reduceCompatible(block: Quantity[]): { points: compatible.map((q) => toBase(q)),
acc: Quantity; unitLabel: block[0].unit?.format ?? null,
counted: number; };
} { },
min: (block) => {
const refDim = block[0].unit?.dimension ?? null;
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
return compatible.reduce((best, q) => toBase(q).lt(toBase(best)) ? q : best);
},
max: (block) => {
const refDim = block[0].unit?.dimension ?? null;
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
return compatible.reduce((best, q) => toBase(q).gt(toBase(best)) ? q : best);
},
product: (block) => qty(block.reduce((acc, q) => acc.mul(q.value), new Decimal(1))),
sum: (block) => {
let acc = block[0];
for (let i = 1; i < block.length; i++) {
try {
acc = numericAdd(acc, block[i], 1);
} catch {
// skip lines with incompatible dimensions
}
}
return acc;
},
avg: (block) => {
let acc = block[0]; let acc = block[0];
let counted = 1; let counted = 1;
for (let i = 1; i < block.length; i++) { for (let i = 1; i < block.length; i++) {
@ -583,29 +517,8 @@ function reduceCompatible(block: Quantity[]): {
// skip lines with incompatible dimensions // 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); return qty(acc.value.div(counted), acc.unit);
}, }
}; };
function getPrevResult(ctx: EvalCtx): Value { function getPrevResult(ctx: EvalCtx): Value {
@ -634,10 +547,7 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
return block; return block;
} }
function evalAgg( function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value {
name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart",
ctx: EvalCtx,
): Value {
if (name === "prev") return getPrevResult(ctx); if (name === "prev") return getPrevResult(ctx);
const block = getAggBlock(ctx); const block = getAggBlock(ctx);
const func = AGG_FUNCS[name]; const func = AGG_FUNCS[name];
@ -645,80 +555,6 @@ function evalAgg(
return func(block); return func(block);
} }
const angleArg = (x: Value): Decimal => {
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const BUILTIN_FUNCS: Record<string, (x: Value, ctx: EvalCtx) => Value> = {
sqrt: (x, ctx) => {
if (
x.kind === "quantity" &&
x.unit?.dimension === "area" &&
x.unit.id.startsWith("sq:")
) {
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
if (base) return qty(x.value.sqrt(), base);
}
return qty(asScalar(x).sqrt());
},
cbrt: (x) => qty(asScalar(x).cbrt()),
sin: (x) => qty(Decimal.sin(angleArg(x))),
cos: (x) => qty(Decimal.cos(angleArg(x))),
tan: (x) => qty(Decimal.tan(angleArg(x))),
cot: (x) => qty(DEC_ONE.div(Decimal.tan(angleArg(x)))),
asin: (x) => qty(Decimal.asin(asScalar(x))),
arcsin: (x) => qty(Decimal.asin(asScalar(x))),
acos: (x) => qty(Decimal.acos(asScalar(x))),
arccos: (x) => qty(Decimal.acos(asScalar(x))),
atan: (x) => qty(Decimal.atan(asScalar(x))),
arctan: (x) => qty(Decimal.atan(asScalar(x))),
sinh: (x) => qty(Decimal.sinh(asScalar(x))),
cosh: (x) => qty(Decimal.cosh(asScalar(x))),
tanh: (x) => qty(Decimal.tanh(asScalar(x))),
ln: (x) => qty(Decimal.ln(asScalar(x))),
lg: (x) => qty(Decimal.log10(asScalar(x))),
log: (x) => qty(Decimal.log10(asScalar(x))),
log2: (x) => qty(Decimal.log2(asScalar(x))),
exp: (x) => qty(Decimal.exp(asScalar(x))),
abs: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.abs() }
: qty(asScalar(x).abs()),
round: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.round() }
: qty(asScalar(x).round()),
ceil: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.ceil() }
: qty(asScalar(x).ceil()),
floor: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.floor() }
: qty(asScalar(x).floor()),
fact: (x) => qty(factorial(asScalar(x))),
factorial: (x) => qty(factorial(asScalar(x))),
until: (x) => {
if (x.kind !== "date") throw new EvalError("until needs a date");
return evalDateArith("minus", x, {
kind: "date",
ms: startOfToday(),
hasTime: false,
});
},
since: (x) => {
if (x.kind !== "date") throw new EvalError("since needs a date");
return evalDateArith(
"minus",
{ kind: "date", ms: startOfToday(), hasTime: false },
x,
);
},
};
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value { 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);
@ -727,27 +563,63 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
const x = args[0]; const x = args[0];
if (!x) throw new EvalError(`${name} needs an argument`); if (!x) throw new EvalError(`${name} needs an argument`);
const func = BUILTIN_FUNCS[name]; // trig works on angles: degrees convert to radians
if (!func) throw new EvalError(`unknown function ${name}`); const angleArg = (): Decimal => {
return func(x, ctx); if (x.kind === "quantity" && x.unit?.dimension === "angle") {
return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const n = () => asScalar(x);
switch (name) {
case "sqrt": {
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
if (base) return qty(x.value.sqrt(), base);
}
return qty(n().sqrt());
}
case "cbrt": return qty(n().cbrt());
case "sin": return qty(Decimal.sin(angleArg()));
case "cos": return qty(Decimal.cos(angleArg()));
case "tan": return qty(Decimal.tan(angleArg()));
case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
case "asin": case "arcsin": return qty(Decimal.asin(n()));
case "acos": case "arccos": return qty(Decimal.acos(n()));
case "atan": case "arctan": return qty(Decimal.atan(n()));
case "sinh": return qty(Decimal.sinh(n()));
case "cosh": return qty(Decimal.cosh(n()));
case "tanh": return qty(Decimal.tanh(n()));
case "ln": return qty(Decimal.ln(n()));
case "lg": case "log": return qty(Decimal.log10(n()));
case "log2": return qty(Decimal.log2(n()));
case "exp": return qty(Decimal.exp(n()));
case "abs": return x.kind === "quantity" ? { ...x, value: x.value.abs() } : qty(n().abs());
case "round": return x.kind === "quantity" ? { ...x, value: x.value.round() } : qty(n().round());
case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
case "fact": case "factorial": return qty(factorial(n()));
case "until": {
if (x.kind !== "date") throw new EvalError("until needs a date");
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
}
case "since": {
if (x.kind !== "date") throw new EvalError("since needs a date");
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
}
default: throw new EvalError(`unknown function ${name}`);
}
} }
// 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 { function evalRandom(args: Value[], ctx: EvalCtx): Value {
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9); const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
const rand = mulberry32(seed); const rand = mulberry32(seed);
if (args.length === 0) return qty(new Decimal(rand)); if (args.length === 0) return qty(new Decimal(rand));
const toNum = (v: Value) => (v.kind === "quantity" ? v.value.toNumber() : 0); const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0;
const [lo, hi] = const [lo, hi] = args.length === 1 ? [0, toNum(args[0]!)] : [toNum(args[0]!), toNum(args[1]!)];
args.length === 1
? [0, toNum(args[0]!)]
: [toNum(args[0]!), toNum(args[1]!)];
const isInt = Number.isInteger(lo) && Number.isInteger(hi); const isInt = Number.isInteger(lo) && Number.isInteger(hi);
const raw = isInt const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
? Math.floor(rand * (hi - lo + 1)) + lo
: rand * (hi - lo) + lo;
return qty(new Decimal(raw)); return qty(new Decimal(raw));
} }
@ -760,121 +632,15 @@ function strHash(s: string): number {
} }
function mulberry32(seed: number): number { function mulberry32(seed: number): number {
seed = (seed + 0x6d2b79f5) | 0; seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
} }
function factorial(n: Decimal): Decimal { function factorial(n: Decimal): Decimal {
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial"); if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
let acc = DEC_ONE; let acc = new Decimal(1);
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i); for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
return acc; return acc;
} }
function evaluateConst(node: {
k: "const";
name: "pi" | "e" | "half" | "onehalf";
}): Value {
switch (node.name) {
case "pi":
return qty(PI);
case "e":
return qty(E);
case "half":
return qty(new Decimal("0.5"));
case "onehalf":
return qty(new Decimal("1.5"));
}
}
function evaluateRef(node: Node, ctx: EvalCtx): Value {
if (node.k === "var") {
const v = ctx.vars.get(node.name);
if (!v) throw new EvalError(`unknown variable ${node.name}`);
return v;
}
if (node.k === "xref") {
const res = ctx.resolveXRef?.(node.sheet, node.key);
if (!res || !res.ok) {
throw new XRefError(
res && !res.ok ? res.reason : `sheet "${node.sheet}" not found`,
);
}
return res.value;
}
if (node.k === "unknown") {
const xv = ctx.vars.get("__x__");
if (!xv) throw new EvalError("? used outside goal seek");
return xv;
}
throw new EvalError("invalid ref node");
}
function evaluateMath(node: Node, ctx: EvalCtx): Value {
if (node.k === "neg") {
const v = evaluate(node.x, ctx);
if (v.kind === "quantity") return { ...v, value: v.value.neg() };
if (v.kind === "percent") return pct(v.value.neg());
throw new EvalError("cannot negate a date");
}
if (node.k === "pct") {
const v = evaluate(node.x, ctx);
if (v.kind === "quantity" && !v.unit) return pct(v.value);
if (v.kind === "percent") return v;
throw new EvalError("% needs a plain number");
}
if (node.k === "fact") {
const v = asScalar(evaluate(node.x, ctx));
return qty(factorial(v));
}
if (node.k === "scale") {
const v = evaluate(node.x, ctx);
if (v.kind === "quantity") return { ...v, value: v.value.mul(node.mult) };
if (v.kind === "percent") return pct(v.value.mul(node.mult));
throw new EvalError("cannot scale a date");
}
throw new EvalError("invalid math node");
}
function evaluateUnitMod(node: Node, ctx: EvalCtx): Value {
if (node.k === "unit") {
const v = evaluate(node.x, ctx);
return attachUnit(v, node.unit);
}
if (node.k === "curr") {
const unit = ctx.reg.makeCurrencyUnit(node.code);
if (!unit) throw new EvalError(`no rate for ${node.code}`);
const v = evaluate(node.x, ctx);
return attachUnit(v, unit);
}
throw new EvalError("invalid unit mod node");
}
function evaluateSeq(node: { k: "seq"; items: Node[] }, ctx: EvalCtx): Value {
let acc: Value | null = null;
for (const item of node.items) {
let v: Value;
try {
v = evaluate(item, ctx);
} catch {
continue;
}
if (
acc?.kind === "quantity" &&
v.kind === "quantity" &&
acc.unit &&
v.unit &&
acc.unit.dimension === v.unit.dimension &&
acc.unit.dimension !== "scalar"
) {
// "2 hours 30 min", "5 ft 4 in" — adjacent same-dimension quantities add up
acc = addQ(acc, v);
} else {
acc = v;
}
}
if (!acc) throw new EvalError("empty expression");
return acc;
}

View file

@ -74,9 +74,6 @@ export function formatValue(v: Value, s: EngineSettings): string {
// 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 +109,6 @@ function withUnit(num: string, f: string): string {
} }
/** Best rational approximation via continued fractions: 0.75 -> "3/4". */ /** Best rational approximation via continued fractions: 0.75 -> "3/4". */
// Native float, not Decimal: the continued-fraction algorithm is tuned to
// float epsilon by design, and this is display formatting, not arithmetic
// carried forward into the result.
function formatFraction(v: Decimal): string { function formatFraction(v: Decimal): string {
const neg = v.isNegative(); const neg = v.isNegative();
const x = v.abs().toNumber(); const x = v.abs().toNumber();

View file

@ -1,10 +1,9 @@
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 { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, 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, XRefResolver, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter"; import { formatValue } from "./formatter";
import { detectDateOrder } from "./datetime"; import { detectDateOrder } from "./datetime";
@ -119,17 +118,12 @@ export class SumEngine {
continue; continue;
} }
// tokenize/parseLine/evaluate all run under one try/catch: pathological const tokens = tokenize(line, this.reg, this.dateOrder);
// input (e.g. stack-overflowing nested parens in the parser) must null const knownVars = new Set(vars.keys());
// out this one line, not throw past evaluateDocument and kill the rest. const parsed = parseLine(tokens, knownVars, line);
let tokens: Token[] = [];
let parsed: ParsedLine = { expr: null };
let value: Value | null = null; let value: Value | null = null;
let lineError: string | undefined; let lineError: string | undefined;
try {
tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys());
parsed = parseLine(tokens, knownVars, line);
if (parsed.expr) { if (parsed.expr) {
const ctx: EvalCtx = { const ctx: EvalCtx = {
reg: this.reg, reg: this.reg,
@ -138,23 +132,18 @@ export class SumEngine {
historicalRates: this.historicalRates, historicalRates: this.historicalRates,
resolveXRef, 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 XRefError) lineError = e.message;
lineError = e.message; else if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
} 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");
@ -219,7 +208,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,

View file

@ -62,18 +62,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[] = [];
@ -93,10 +81,7 @@ function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
afterConv = tk.t === "conv"; afterConv = tk.t === "conv";
filtered.push(tk); filtered.push(tk);
} }
return filtered;
}
function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | null {
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x) // Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
const hasUnknown = filtered.some((tk) => tk.t === "unknown"); const hasUnknown = filtered.some((tk) => tk.t === "unknown");
if (hasUnknown) { if (hasUnknown) {
@ -104,23 +89,14 @@ function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | nul
if (assignIdx >= 0) { if (assignIdx >= 0) {
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq(); const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq(); const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq();
if (lhs && rhs) return { k: "goalseek", lhs, rhs }; if (lhs && rhs) return { expr: { k: "goalseek", lhs, rhs } };
} }
} }
return null;
const p = new Parser(filtered, knownVars);
return { assign, expr: p.parseSeq() };
} }
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>) {}
@ -154,7 +130,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 +141,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 +152,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 +161,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,161 +169,52 @@ 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: this.tryHistoricalDate(target) };
} else if (tk?.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
this.i++;
const r = this.parseAdd();
if (!r) break;
l = { k: "pctop", op: tk.op, l, r };
} else {
break;
} }
}
return l;
}
/** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */ /** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */
private tryHistoricalDate(target: ConvTarget): ConvTarget { private tryHistoricalDate(target: ConvTarget): ConvTarget {
if (target.type !== "currency") return target; if (target.type !== "currency") return target;
@ -413,6 +280,122 @@ class Parser {
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;
@ -448,7 +431,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 +452,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;
} }

View file

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

View file

@ -8,7 +8,6 @@ import * as vocab from "./vocab";
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata"; import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
import { EXTRA_UNITS, CRYPTO } from "./extraunits"; import { EXTRA_UNITS, CRYPTO } from "./extraunits";
/** Operators for percentage calculations. */
export type PctOp = export type PctOp =
| "of" | "off" | "on" | "of" | "off" | "on"
| "as_pct_of" | "as_pct_off" | "as_pct_on" | "as_pct_of" | "as_pct_off" | "as_pct_on"
@ -178,29 +177,29 @@ function registerCoreVocab(reg: Registry): void {
["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", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "chart.variants")) reg.addPhrase(v, { t: "agg", name: "chart" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false });
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false }); reg.addPhrase("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 });

View file

@ -115,7 +115,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 +138,3 @@ function disambiguateIn(tokens: Token[], reg: Registry, line: string): Token[] {
return tk; return tk;
}); });
} }
/**
* "5 min": the "min" aggregate keyword (registered first, so it wins the
* phrase in the registry) collides with the minute unit's "min"/"мин"
* abbreviation. Right after a number it can only mean minutes — the
* standalone `min` aggregate line has no preceding number to collide with.
*/
function disambiguateMinAgg(tokens: Token[], reg: Registry, line: string): Token[] {
const minute = reg.unitsById.get("minute");
if (!minute) return tokens;
return tokens.map((tk, i) => {
if (tk.t !== "agg" || tk.name !== "min") return tk;
const raw = line.slice(tk.start, tk.end).toLowerCase();
if (raw !== "min" && raw !== "мин") return tk;
if (tokens[i - 1]?.t !== "num") return tk;
return { t: "unit", unit: minute, start: tk.start, end: tk.end } as Token;
});
}

View file

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

View file

@ -4,11 +4,6 @@ Decimal.set({ precision: 40, toExpNeg: -9, toExpPos: 21 });
export { Decimal }; export { Decimal };
/** Shared here (not in evaluator.ts) so unitdata.ts can use it too without a cycle. */
export const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
export const DEC_ZERO = new Decimal(0);
export const DEC_ONE = new Decimal(1);
export type Dimension = export type Dimension =
| "scalar" | "scalar"
| "length" | "length"

View file

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

View file

@ -169,13 +169,11 @@ export const EN: VocabLocale = {
"KES.format": "%@ KSh", "KES.format": "%@ KSh",
"KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh", "KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh",
"KGS.format": "%@ sом", "KGS.format": "%@ sом",
// bare "Som"/"Soms" dropped — UZS (larger economy) owns it; say "Kyrgyzstani Som" for KGS "KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,Soms,Som,sом",
"KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,sом",
"KHR.format": "%@ R.", "KHR.format": "%@ R.",
"KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels", "KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels",
"KMF.format": "%@ FC", "KMF.format": "%@ FC",
// bare "FC" dropped — CDF (Congolese franc, registered first) owns it; say "Comorian Franc" for KMF "KMF.variants": "Comorian Francs,Comorian Franc,FC",
"KMF.variants": "Comorian Francs,Comorian Franc",
"KPW.format": "₩ %@", "KPW.format": "₩ %@",
"KPW.variants": "North Korean won", "KPW.variants": "North Korean won",
"KRW.format": "₩ %@", "KRW.format": "₩ %@",
@ -223,8 +221,7 @@ export const EN: VocabLocale = {
"MVR.format": "Rf %@", "MVR.format": "Rf %@",
"MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf", "MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf",
"MWK.format": "%@ MK", "MWK.format": "%@ MK",
// bare "Kwacha" dropped — ZMW (larger economy) owns it; say "Malawian Kwacha" for MWK "MWK.variants": "Malawian Kwacha,Kwacha",
"MWK.variants": "Malawian Kwacha",
"MXN.format": "$ %@", "MXN.format": "$ %@",
"MXN.variants": "Mexican Peso,Mexican Pesos", "MXN.variants": "Mexican Peso,Mexican Pesos",
"MYR.format": "RM %@", "MYR.format": "RM %@",
@ -304,8 +301,7 @@ export const EN: VocabLocale = {
"TJS.format": "%@ TJS", "TJS.format": "%@ TJS",
"TJS.variants": "Tajikistani Somoni,Somoni", "TJS.variants": "Tajikistani Somoni,Somoni",
"TMT.format": "%@ TMT", "TMT.format": "%@ TMT",
// bare "Manat" dropped — AZN (registered first) owns it; say "Turkmenistan Manat" for TMT "TMT.variants": "Turkmenistan Manat,Manat",
"TMT.variants": "Turkmenistan Manat",
"TND.format": "%@ TD", "TND.format": "%@ TD",
"TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD", "TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD",
"TOP.format": "T$ %@", "TOP.format": "T$ %@",
@ -737,8 +733,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 +793,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": "Тонганская паанга,Паанга,Паангах",

View file

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

View file

@ -1,4 +1,3 @@
import { logger } from "./logger";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { makeApi, runExtensions } from "./extensions"; import { makeApi, runExtensions } from "./extensions";
import { SumEngine } from "./engine"; import { SumEngine } from "./engine";
@ -42,8 +41,8 @@ describe("extensions", () => {
let consoleWarnSpy: any; let consoleWarnSpy: any;
beforeEach(() => { beforeEach(() => {
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {
@ -131,10 +130,13 @@ describe("extensions", () => {
it("has no host globals inside the sandbox", async () => { it("has no host globals inside the sandbox", async () => {
await runExtensions(mockEngine as SumEngine, [ await runExtensions(mockEngine as SumEngine, [
{ name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');" }, { name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined') throw new Error('leak');" },
{ name: "log", code: "console.log('hi');" },
]); ]);
expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension log failed:");
expect(consoleErrorSpy.mock.calls[0][1].message).toMatch(/console/);
}); });
it("interrupts an infinite loop at load time", async () => { it("interrupts an infinite loop at load time", async () => {
@ -186,8 +188,6 @@ describe("extensions", () => {
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")), newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
})); }));
const { runExtensions: run } = await import("./extensions"); 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);" }]); await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);

View file

@ -1,21 +1,13 @@
import { logger } from "./logger";
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction. // JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals. // Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
import { import { newQuickJSWASMModuleFromVariant, QuickJSContext, QuickJSHandle } from "quickjs-emscripten-core";
newQuickJSWASMModuleFromVariant,
QuickJSContext,
QuickJSHandle,
} from "quickjs-emscripten-core";
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync"; import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine"; import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
interface ExtensionApi { 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 {
@ -37,17 +29,14 @@ const LOAD_TIMEOUT_MS = 1000;
// every keystroke for every line that uses them — this budget must stay small. // every keystroke for every line that uses them — this budget must stay small.
const CALL_TIMEOUT_MS = 50; const CALL_TIMEOUT_MS = 50;
const now = () => const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
typeof performance !== "undefined" ? performance.now() : Date.now();
function toExtValue(v: unknown, what: string): ExtensionValue | number { function toExtValue(v: unknown, what: string): ExtensionValue | number {
if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "number" && Number.isFinite(v)) return v;
if (v && typeof v === "object") { if (v && typeof v === "object") {
const o = v as { double?: unknown; unitId?: unknown }; const o = v as { double?: unknown; unitId?: unknown };
if (typeof o.double === "number" && Number.isFinite(o.double)) { if (typeof o.double === "number" && Number.isFinite(o.double)) {
return typeof o.unitId === "string" return typeof o.unitId === "string" ? { double: o.double, unitId: o.unitId } : { double: o.double };
? { double: o.double, unitId: o.unitId }
: { double: o.double };
} }
} }
throw new Error(`${what}: expected a number or { double, unitId? }`); throw new Error(`${what}: expected a number or { double, unitId? }`);
@ -55,11 +44,7 @@ function toExtValue(v: unknown, what: string): ExtensionValue | number {
function message(e: unknown): string { function message(e: unknown): string {
if (e instanceof Error) return e.message; if (e instanceof Error) return e.message;
if ( if (e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string") {
e &&
typeof e === "object" &&
typeof (e as { message?: unknown }).message === "string"
) {
return (e as { message: string }).message; return (e as { message: string }).message;
} }
return String(e); return String(e);
@ -68,27 +53,29 @@ function message(e: unknown): string {
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a // The sandbox has no console/setTimeout/fetch: an extension touching those gets a
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure. // 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. // Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app.
export async function runExtensions( export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
engine: SumEngine,
scripts: { name: string; code: string }[],
): Promise<void> {
if (scripts.length === 0) return; if (scripts.length === 0) return;
try { try {
await load(engine, scripts); await load(engine, scripts);
} catch (e) { } catch (e) {
logger.warn("extensions disabled: QuickJS sandbox unavailable:", e); console.warn("extensions disabled: QuickJS sandbox unavailable:", e);
} }
} }
type RunWithTimeout = <T>(timeoutMs: number, fn: () => T) => T; 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);
function callGuest( // Armed before every entry into the sandbox and disarmed after, so a hung script
vm: QuickJSContext, // at load time and a hung function call on keystroke N are both bounded.
id: string, let deadline = Infinity;
fn: QuickJSHandle, vm.runtime.setInterruptHandler(() => now() > deadline);
values: ExtensionValue[],
runWithTimeout: RunWithTimeout, const api = makeApi(engine);
): ExtensionValue | number {
const callGuest = (id: string, fn: QuickJSHandle, values: ExtensionValue[]): ExtensionValue | number => {
// extension-sample.js convention: the callback takes one array of values. // extension-sample.js convention: the callback takes one array of values.
const arr = vm.newArray(); const arr = vm.newArray();
try { try {
@ -105,9 +92,13 @@ function callGuest(
vm.setProp(arr, i, o); vm.setProp(arr, i, o);
o.dispose(); o.dispose();
}); });
const res = runWithTimeout(CALL_TIMEOUT_MS, () => let res;
vm.callFunction(fn, vm.undefined, [arr]), deadline = now() + CALL_TIMEOUT_MS;
); try {
res = vm.callFunction(fn, vm.undefined, [arr]);
} finally {
deadline = Infinity;
}
if (res.error) { if (res.error) {
const err = vm.dump(res.error); const err = vm.dump(res.error);
res.error.dispose(); res.error.dispose();
@ -119,13 +110,8 @@ function callGuest(
} finally { } finally {
arr.dispose(); arr.dispose();
} }
} };
function exposeApi(
vm: QuickJSContext,
api: ExtensionApi,
runWithTimeout: RunWithTimeout,
) {
const numi = vm.newObject(); const numi = vm.newObject();
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => { const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
const fn = vm.newFunction(name, (...args) => { const fn = vm.newFunction(name, (...args) => {
@ -141,19 +127,13 @@ function exposeApi(
define("setVariable", (nameHandle, valueHandle) => { define("setVariable", (nameHandle, valueHandle) => {
const name = vm.dump(nameHandle); const name = vm.dump(nameHandle);
if (typeof name !== "string" || !name) if (typeof name !== "string" || !name) throw new Error("expects a variable name");
throw new Error("expects a variable name");
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value")); api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
}); });
define("addUnit", (specHandle) => { define("addUnit", (specHandle) => {
const spec = vm.dump(specHandle); const spec = vm.dump(specHandle);
if ( if (!spec || typeof spec.id !== "string" || typeof spec.baseUnitId !== "string" || typeof spec.ratio !== "number") {
!spec ||
typeof spec.id !== "string" ||
typeof spec.baseUnitId !== "string" ||
typeof spec.ratio !== "number"
) {
throw new Error("expects { id, phrases, baseUnitId, ratio }"); throw new Error("expects { id, phrases, baseUnitId, ratio }");
} }
api.addUnit({ api.addUnit({
@ -167,33 +147,28 @@ function exposeApi(
define("addFunction", (specHandle, fnHandle) => { define("addFunction", (specHandle, fnHandle) => {
const spec = vm.dump(specHandle); const spec = vm.dump(specHandle);
if (!spec || typeof spec.id !== "string") if (!spec || typeof spec.id !== "string") throw new Error("expects { id, phrases }");
throw new Error("expects { id, phrases }"); if (!fnHandle || vm.typeof(fnHandle) !== "function") throw new Error("expects a function");
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 // outlives this callback, so it needs its own reference; the context is never disposed
const guestFn = fnHandle.dup(); const guestFn = fnHandle.dup();
const id = spec.id; const id = spec.id;
api.addFunction( api.addFunction({ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, (values) =>
{ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, callGuest(id, guestFn, values),
(values) => callGuest(vm, id, guestFn, values, runWithTimeout),
); );
}); });
vm.setProp(vm.global, "numi", numi); vm.setProp(vm.global, "numi", numi);
numi.dispose(); 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, () => let res;
vm.evalCode(s.code, s.name), deadline = now() + LOAD_TIMEOUT_MS;
); try {
res = vm.evalCode(s.code, s.name);
} finally {
deadline = Infinity;
}
if (res.error) { if (res.error) {
const err = vm.dump(res.error); const err = vm.dump(res.error);
res.error.dispose(); res.error.dispose();
@ -201,34 +176,7 @@ function evaluateScripts(
} }
res.value.dispose(); res.value.dispose();
} catch (e) { } catch (e) {
logger.error(`extension ${s.name} failed:`, e); console.error(`extension ${s.name} failed:`, e);
} }
} }
} }
async function load(
engine: SumEngine,
scripts: { name: string; code: string }[],
): Promise<void> {
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
const vm: QuickJSContext = mod.newContext();
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
vm.runtime.setMaxStackSize(STACK_SIZE);
// Armed before every entry into the sandbox and disarmed after, so a hung script
// at load time and a hung function call on keystroke N are both bounded.
let deadline = Infinity;
vm.runtime.setInterruptHandler(() => now() > deadline);
const runWithTimeout: RunWithTimeout = (timeoutMs, fn) => {
deadline = now() + timeoutMs;
try {
return fn();
} finally {
deadline = Infinity;
}
};
exposeApi(vm, makeApi(engine), runWithTimeout);
evaluateScripts(vm, scripts, runWithTimeout);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { logger } from "./logger";
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`. // Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
export interface DocMeta { export interface DocMeta {
@ -80,7 +79,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 +99,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 +132,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 +150,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 +159,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 +167,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,33 +206,13 @@ 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 // ---------- 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> { export async function fetchHistoricalRates(date: string): Promise<Record<string, number> | null> {
try { try {
if (isTauri()) { if (isTauri()) {
@ -247,7 +227,7 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
} }
return out; return out;
} catch (e) { } catch (e) {
logger.warn("fetchHistoricalRates failed", date, e); console.warn("fetchHistoricalRates failed", date, e);
return null; return null;
} }
} }
@ -266,7 +246,7 @@ export async function fetchMarketData(symbols: string[]): Promise<Record<string,
try { try {
return await invoke<Record<string, number>>("fetch_market_data", { symbols }); return await invoke<Record<string, number>>("fetch_market_data", { symbols });
} catch (e) { } catch (e) {
logger.warn("fetchMarketData failed", e); console.warn("fetchMarketData failed", e);
return {}; return {};
} }
} }
@ -278,7 +258,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 +295,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
const content = await invoke<string>("read_text_file", { path }); const content = await invoke<string>("read_text_file", { path });
cb(content); cb(content);
} catch (e) { } catch (e) {
logger.warn("file drop rejected", path, e); console.warn("file drop rejected", path, e);
} }
} }
}); });

View file

@ -654,4 +654,3 @@ body {
} }
.cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); } .cm-tooltip-autocomplete ul li { font-family: var(--mono); font-size: 13px; color: var(--fg); }
.cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); } .cm-tooltip-autocomplete ul li[aria-selected] { background: var(--sel); color: var(--fg); }
.d-none { display: none !important; }

View file

@ -5,7 +5,6 @@ import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirr
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;
@ -75,8 +74,6 @@ const highlightField = StateField.define<DecorationSet>({
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,21 +89,11 @@ 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();
@ -154,6 +141,52 @@ const varHighlightPlugin = ViewPlugin.fromClass(
} }
); );
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;
}
export class SumEditor { export class SumEditor {
view: EditorView; view: EditorView;
private resultsEl: HTMLElement; private resultsEl: HTMLElement;
@ -170,7 +203,6 @@ export class SumEditor {
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 +215,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 };
}; };

View file

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

View file

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

View file

@ -30,13 +30,9 @@ export interface SearchController {
open(): void; open(): void;
} }
const $ = <T extends HTMLElement>(sel: string): T => const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
document.querySelector(sel) as T;
export function parseResultQuery( function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
engine: SumEngine,
q: string,
): { op: string; threshold: Value } | null {
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim()); const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
if (!m) return null; if (!m) return null;
const v = engine.evaluateExpression(m[2].trim()); const v = engine.evaluateExpression(m[2].trim());
@ -44,7 +40,7 @@ export function parseResultQuery(
return { op: m[1], threshold: v }; return { op: m[1], threshold: v };
} }
export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] { function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const q = query.trim(); const q = query.trim();
if (!q) return []; if (!q) return [];
const docs = deps.docs(); const docs = deps.docs();
@ -54,41 +50,22 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const hits: SearchHit[] = []; const hits: SearchHit[] = [];
for (const doc of docs) { for (const doc of docs) {
const results = deps.workspace.getCachedResults(doc); const results = deps.workspace.getCachedResults(doc);
let lines: string[] | undefined; const lines = doc.text.split("\n");
for (let i = 0; i < results.length; i++) { for (let i = 0; i < results.length; i++) {
const r = results[i]; const r = results[i];
if (!r.value || r.value.kind !== "quantity") continue; if (!r.value || r.value.kind !== "quantity") continue;
const v = r.value.value; const v = r.value.value;
let match = false; let match = false;
switch (rq.op) { switch (rq.op) {
case ">": case ">": match = v.gt(th); break;
match = v.gt(th); case ">=": match = v.gte(th); break;
break; case "<": match = v.lt(th); break;
case ">=": case "<=": match = v.lte(th); break;
match = v.gte(th); case "=": match = v.eq(th); break;
break; case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); 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 (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 });
hits.push({
docId: doc.id,
docTitle: doc.title,
line: i + 1,
text: lines[i] ?? "",
result: r.text ?? undefined,
});
if (hits.length >= 200) return hits; if (hits.length >= 200) return hits;
} }
} }
@ -101,12 +78,7 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const lines = doc.text.split("\n"); const lines = doc.text.split("\n");
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(ql)) { if (lines[i].toLowerCase().includes(ql)) {
hits.push({ hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] });
docId: doc.id,
docTitle: doc.title,
line: i + 1,
text: lines[i],
});
if (hits.length >= 200) return hits; if (hits.length >= 200) return hits;
} }
} }
@ -141,9 +113,7 @@ export function initSearch(deps: SearchDeps): SearchController {
docEl.textContent = hit.docTitle; docEl.textContent = hit.docTitle;
const lineEl = document.createElement("div"); const lineEl = document.createElement("div");
lineEl.className = "line"; lineEl.className = "line";
lineEl.textContent = hit.result lineEl.textContent = hit.result ? `${hit.text} = ${hit.result}` : hit.text;
? `${hit.text} = ${hit.result}`
: hit.text;
item.append(docEl, lineEl); item.append(docEl, lineEl);
item.addEventListener("click", () => { item.addEventListener("click", () => {
deps.onOpen(hit.docId, hit.line); deps.onOpen(hit.docId, hit.line);

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { logger } from "./logger";
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { checkForUpdate } from './updater'; import { checkForUpdate } from './updater';
import * as storage from './storage'; import * as storage from './storage';
@ -23,8 +22,8 @@ vi.mock('@tauri-apps/plugin-process', () => ({
describe('checkForUpdate', () => { describe('checkForUpdate', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.spyOn(logger, 'info').mockImplementation(() => {}); vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(logger, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {});
}); });
it('should return null if not in Tauri', async () => { it('should return null if not in Tauri', async () => {
@ -40,7 +39,7 @@ describe('checkForUpdate', () => {
const result = await checkForUpdate(); const result = await checkForUpdate();
expect(result).toBe('error'); expect(result).toBe('error');
expect(logger.warn).toHaveBeenCalledWith('update check failed', expect.any(Error)); expect(console.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
}); });
it('should return null if no update is available', async () => { it('should return null if no update is available', async () => {
@ -53,7 +52,7 @@ describe('checkForUpdate', () => {
const result = await checkForUpdate(); const result = await checkForUpdate();
expect(result).toBeNull(); expect(result).toBeNull();
expect(logger.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release"); expect(console.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 () => { it('should return an update object if an update is available', async () => {

View file

@ -1,4 +1,3 @@
import { logger } from "./logger";
import { isTauri } from "./storage"; import { isTauri } from "./storage";
interface AvailableUpdate { interface AvailableUpdate {
@ -20,10 +19,10 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
// visible in devtools console — the fastest way to tell "already on // visible in devtools console — the fastest way to tell "already on
// the latest published release" apart from "the check silently failed" // the latest published release" apart from "the check silently failed"
const { getVersion } = await import("@tauri-apps/api/app"); const { getVersion } = await import("@tauri-apps/api/app");
logger.info(`no update: running ${await getVersion()}, that's the latest published release`); console.info(`no update: running ${await getVersion()}, that's the latest published release`);
return null; return null;
} }
logger.info(`update available: ${update.currentVersion} -> ${update.version}`); console.info(`update available: ${update.currentVersion} -> ${update.version}`);
return { return {
version: update.version, version: update.version,
async install() { async install() {
@ -33,7 +32,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
}, },
}; };
} catch (e) { } catch (e) {
logger.warn("update check failed", e); console.warn("update check failed", e);
return "error"; return "error";
} }
} }

View file

@ -44,19 +44,6 @@ describe("Workspace exports", () => {
}); });
}); });
describe("Workspace error handling", () => {
it("cleans up the resolving set if evaluation throws an unexpected error", () => {
const { ws, engine } = makeWorkspace({
a: { title: "A", text: "1 + 1" },
});
vi.spyOn(engine, "evaluateDocument").mockImplementation(() => {
throw new Error("mocked crash");
});
expect(() => ws.evaluateSheet("a", "1 + 1")).toThrowError("mocked crash");
expect((ws as any).resolving.has("a")).toBe(false);
});
});
describe("Workspace invalidation", () => { describe("Workspace invalidation", () => {
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => { it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
const { ws, store } = makeWorkspace({ const { ws, store } = makeWorkspace({

View file

@ -32,7 +32,8 @@ export class Workspace {
constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {} constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {}
private doEvaluate(sheetId: string, text: string): LineResult[] { /** Evaluate a sheet as the user's active/open document — refs resolve live. */
evaluateSheet(sheetId: string, text: string): LineResult[] {
this.resolving.add(sheetId); this.resolving.add(sheetId);
try { try {
return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key)); return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key));
@ -41,11 +42,6 @@ export class Workspace {
} }
} }
/** 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[] { getCachedResults(sheet: SheetSource): LineResult[] {
return this.exportsFor(sheet).results; return this.exportsFor(sheet).results;
} }
@ -54,11 +50,10 @@ export class Workspace {
invalidate(sheetId: string): void { invalidate(sheetId: string): void {
this.titleCache = null; this.titleCache = null;
const dirty = new Set<string>([sheetId]); const dirty = new Set<string>([sheetId]);
const sheetsList = this.sheets();
let changed = true; let changed = true;
while (changed) { while (changed) {
changed = false; changed = false;
for (const s of sheetsList) { for (const s of this.sheets()) {
if (dirty.has(s.id)) continue; if (dirty.has(s.id)) continue;
for (const title of this.referencedTitles(s.text, s.id)) { for (const title of this.referencedTitles(s.text, s.id)) {
const target = this.findSheetByTitle(title); const target = this.findSheetByTitle(title);
@ -150,7 +145,13 @@ export class Workspace {
private exportsFor(sheet: SheetSource): SheetExports { private exportsFor(sheet: SheetSource): SheetExports {
const cached = this.cache.get(sheet.id); const cached = this.cache.get(sheet.id);
if (cached) return cached; if (cached) return cached;
const results = this.doEvaluate(sheet.id, sheet.text); this.resolving.add(sheet.id);
let results: LineResult[];
try {
results = this.engine.evaluateDocument(sheet.text, (s, k) => this.resolveXRef(s, k));
} finally {
this.resolving.delete(sheet.id);
}
const vars = new Map<string, Value>(); const vars = new Map<string, Value>();
for (const r of results) { for (const r of results) {
if (r.assign && r.value) vars.set(r.assign, r.value); if (r.assign && r.value) vars.set(r.assign, r.value);