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
# tauri-action's find-or-create-release check races across parallel
# matrix jobs and can create two separate release objects for the
# 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.
# same tag, each missing the other's assets — force serial builds
max-parallel: 1
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
- target: x86_64-pc-windows-msvc
arch: x64
- os: windows-latest
target: aarch64-pc-windows-msvc
- target: aarch64-pc-windows-msvc
arch: arm64
# 22.04 is the oldest runner with webkit2gtk-4.1 (what Tauri 2
# 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 }}
runs-on: windows-latest
steps:
- 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
with:
node-version: 22
@ -64,19 +42,9 @@ jobs:
- 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
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
@ -95,27 +63,17 @@ jobs:
fetch-depth: 0
- env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
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"
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 "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$prev...${{ github.ref_name }}"
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
} > notes.md
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
search.ts cross-sheet search overlay (text + result-value queries)
export.ts renders the sheet + results to a PNG (clipboard/file)
sparkline.ts SVG sparkline renderer for `chart` results
app.css themes (light/dark via CSS variables)
main.ts app bootstrap: documents, settings, status bar, dnd
workspace.ts cross-sheet references: exports cache, cycle detection
storage.ts Tauri commands / localStorage fallback
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
to the engine's numi.* API
i18n.ts interface strings (en/ru)
updater.ts checks tauri-plugin-updater, installs + relaunches
logger.ts thin console wrapper (info/warn/error)
src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
@ -218,40 +215,6 @@ recursing forever.
deleted sheets become `.numi` files under `backups/deleted/` and are pruned
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
- `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
button ignores the toggle and always works.
- The update endpoint is `latest.json` published alongside each GitHub
Release. `release.yml` signs every platform's updater artifact with a
minisign keypair (this is Tauri's own update signing, unrelated to OS code
signing):
Release. `release.yml` signs both architectures with a minisign keypair:
the public half is embedded in `tauri.conf.json`; the private half + its
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
@ -303,7 +264,7 @@ All files below live in `%APPDATA%/app.summarum.calc` (or the user-chosen folder
## 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,
historical rates (injected), date-format literals/display (dedicated engines
with an explicit `dateFormat`, since the default "system" format depends on

View file

@ -24,16 +24,13 @@ src/
editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование
search.ts оверлей поиска по всем листам (текст + запросы по значению)
export.ts рендерит лист + результаты в PNG (буфер обмена/файл)
sparkline.ts рендер SVG-спарклайна для результатов `chart`
app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd
workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов
storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы в песочнице
QuickJS/WASM, мост к API numi.* движка
i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск
logger.ts тонкая обёртка над console (info/warn/error)
src-tauri/
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
@ -220,40 +217,6 @@ src-tauri/
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
Сохранение откладывается на 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` при запуске; если
@ -282,9 +245,7 @@ src-tauri/
тике интервала, так что переключение прямо во время работы срабатывает
сразу); ручная кнопка тумблер игнорирует и работает всегда.
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
Release. `release.yml` подписывает артефакт обновления каждой платформы парой
ключей minisign (это собственная подпись обновлений Tauri, к подписи кода в ОС
отношения не имеет):
Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
(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» по

View file

@ -29,29 +29,16 @@ Works in **English and Russian** at the same time: `5 метров в см`, `20
## Install
Grab the build for your system from [Releases](../../releases). The whole app
is about 2 MB and 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.
Grab the latest `Summarum_*_x64-setup.exe` (or `arm64-setup.exe` on Windows
on ARM) from [Releases](../../releases), run it, done. The whole app is
about 2 MB. It checks for updates on its own after that.
Requirements:
- **Windows 10 (1803+) or Windows 11**, 64-bit, plus the
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
- **Windows 10 (1803+) or Windows 11**, 64-bit.
- **[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
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
else works offline (rates fall back to a cached/bundled snapshot).
@ -169,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
```bash
npm install
npm test # engine tests (373 cases)
npm test # engine tests (259 cases)
npm run tauri dev # run the app
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's built-in TypeScript support)
- [Rust](https://rustup.rs/), stable
- On Windows: the MSVC toolchain plus
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
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
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.
## Credits
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
scratch for Windows, Linux and macOS.
scratch for Windows.
## License

View file

@ -27,28 +27,14 @@ AAPL в USD → $213.55
## Установка
Скачай сборку под свою систему из [Releases](../../releases). Приложение весит
около 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` в терминале).
Дальше запускается как обычно.
Скачай последний `Summarum_*_x64-setup.exe` (или `arm64-setup.exe` для Windows on ARM) из [Releases](../../releases), запусти — готово. Весь установщик около 2 МБ. Дальше обновления проверяются сами.
Требования:
- **Windows 10 (1803+) или Windows 11**, 64-бит, и
**[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
- **Windows 10 (1803+) или Windows 11**, 64-бит.
- **[WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)** —
уже встроен в 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
npm install
npm test # тесты движка (373 кейса)
npm test # тесты движка (259 кейсов)
npm run tauri dev # запустить приложение
npm run tauri build # собрать установщик
```
@ -173,25 +159,17 @@ npm run tauri build # собрать установщик
Для сборки нужны:
- [Node.js](https://nodejs.org/) 22.6 или новее
- [Rust](https://rustup.rs/), stable
- На Windows: toolchain MSVC и
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
- [Rust](https://rustup.rs/) (stable, MSVC toolchain)
- [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
с компонентом «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/)
`npm run tauri build` собирает то, что умеет хост-платформа: NSIS на Windows,
deb/rpm/AppImage на Linux, .app/.dmg на macOS. Кросс-компиляция между ними не
поддерживается — релизный workflow собирает каждую платформу на своём раннере.
Стек: [Tauri 2](https://tauri.app) + TypeScript + CodeMirror 6.
## Благодарность
Вдохновлён [Numi](https://numi.app) от Дмитрия Николаева —
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows, Linux и macOS.
если ты на macOS, купи его. Summarum — независимый проект, написанный с нуля для Windows.
## Лицензия

View file

@ -30,7 +30,7 @@
</div>
<div id="statusbar">
<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>
<div id="export-wrap">
<button id="export-btn" title="Export" aria-label="Export">⤓</button>

10
package-lock.json generated
View file

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

View file

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

View file

@ -113,7 +113,7 @@ function draw(size: number): Buffer {
fs.mkdirSync(outDir, { recursive: true });
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)));
}
@ -154,38 +154,3 @@ function buildIco(sizes: number[]): Buffer {
fs.writeFileSync(path.join(outDir, "icon.ico"), buildIco([16, 32, 48, 64, 128, 256]));
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",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@ -3058,14 +3057,12 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
@ -3105,7 +3102,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"wasm-streams",
"web-sys",
]
@ -3727,7 +3724,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "summarum"
version = "1.2.9"
version = "1.2.3"
dependencies = [
"chrono",
"futures",
@ -4927,19 +4924,6 @@ dependencies = [
"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]]
name = "wasm-streams"
version = "0.5.0"

View file

@ -1,7 +1,7 @@
[package]
name = "summarum"
version = "1.2.9"
description = "Summarum — natural language calculator notebook"
version = "1.2.3"
description = "Summarum — natural language calculator notebook for Windows"
edition = "2021"
[build-dependencies]
@ -18,7 +18,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
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"
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,
// currency rates fetching with an on-disk cache, extension script loading.
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{Menu, MenuItem};
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_opener::OpenerExt;
const RATES_TTL_SECS: u64 = 3600;
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 {
let dir = app.path().app_data_dir().expect("no app data dir");
@ -31,124 +21,17 @@ fn data_dir(app: &AppHandle) -> PathBuf {
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
/// 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>) -> Result<PathBuf, String> {
let requested = dir.as_deref().map(str::trim).unwrap_or("");
if requested.is_empty() {
return Ok(data_dir(app));
fn docs_dir(app: &AppHandle, dir: &Option<String>) -> PathBuf {
match dir {
Some(d) if !d.trim().is_empty() => {
let p = PathBuf::from(d);
fs::create_dir_all(&p).ok();
p
}
_ => data_dir(app),
}
let candidate = PathBuf::from(requested);
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 {
@ -191,7 +74,13 @@ fn write_image_file(app: AppHandle, data_base64: String) -> Result<bool, String>
.blocking_save_file()
{
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)
} else {
Ok(false)
@ -216,9 +105,6 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
})
.collect::<Result<_, _>>()?;
for chunk in bytes.chunks(4) {
if chunk.len() < 2 {
return Err("invalid base64 padding".into());
}
let b0 = chunk[0];
let b1 = *chunk.get(1).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)
}
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
fn write_atomic_bytes(path: &Path, contents: &[u8]) -> Result<(), String> {
let count = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = path.with_extension(format!("tmp.{}", count));
fn write_atomic(path: &Path, contents: &str) -> Result<(), String> {
let tmp = path.with_extension("tmp");
fs::write(&tmp, contents).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]
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());
}
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]
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());
}
let path = data_dir(&app).join(name);
@ -333,46 +195,26 @@ fn now_secs() -> u64 {
.as_secs()
}
async fn read_rates_cache(app: &AppHandle) -> Option<RatesCache> {
let raw = read_file_async(data_dir(app).join("rates.json")).await?;
fn read_rates_cache(app: &AppHandle) -> Option<RatesCache> {
let raw = fs::read_to_string(data_dir(app).join("rates.json")).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> {
let client = HTTP_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_default()
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let resp = client
let body: serde_json::Value = client
.get("https://open.er-api.com/v6/latest/USD")
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let bytes = read_body_capped(resp, RATES_RESPONSE_LIMIT).await?;
let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if body["result"] != "success" {
return Err("rates api error".into());
}
@ -385,13 +227,11 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
ids.join(",")
);
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) = serde_json::from_slice::<serde_json::Value>(&bytes) {
for (code, id) in CRYPTO {
if let Some(price) = json[id]["usd"].as_f64() {
if price > 0.0 {
rates.insert((*code).into(), serde_json::json!(1.0 / price));
}
if let Ok(json) = resp.json::<serde_json::Value>().await {
for (code, id) in CRYPTO {
if let Some(price) = json[id]["usd"].as_f64() {
if price > 0.0 {
rates.insert((*code).into(), serde_json::json!(1.0 / price));
}
}
}
@ -411,7 +251,7 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
#[tauri::command]
async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String> {
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 {
let mut payload = cache.payload;
payload.fetched_at = cache.fetched_at;
@ -426,12 +266,11 @@ async fn fetch_rates(app: AppHandle, force: bool) -> Result<RatesPayload, String
payload: payload.clone(),
};
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)
}
Err(e) => read_rates_cache(&app)
.await
.map(|c| {
let mut payload = c.payload;
payload.fetched_at = c.fetched_at;
@ -459,7 +298,7 @@ async fn fetch_market_data(
let cache_path = data_dir(&app).join("market.json");
// 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 now_secs().saturating_sub(cache.fetched_at) < MARKET_TTL_SECS {
let syms: std::collections::HashSet<&str> =
@ -523,7 +362,7 @@ async fn fetch_market_data(
prices: prices.clone(),
};
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()))
}
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]
async fn fetch_historical_rates(
app: AppHandle,
@ -569,111 +389,47 @@ async fn fetch_historical_rates(
return Err("invalid date format".into());
}
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) {
return Ok(cached);
}
}
let client = HTTP_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.unwrap_or_default()
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
// api.frankfurter.app 301-redirects here now, and the old domain is
// flaky over reqwest/rustls-tls — go straight to the stable host
let url = format!("https://api.frankfurter.dev/v1/{}?from=USD", date);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
let 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())?;
let rates = parse_rate_map(body["rates"].as_object().ok_or("no rates in response")?);
let body: serde_json::Value = client
.get(&url)
.send()
.await
.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) {
write_atomic_async(cache_path, serialized).await;
write_atomic(&cache_path, &serialized).ok();
}
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
#[tauri::command]
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() {
return Ok(None);
}
@ -684,7 +440,7 @@ fn load_documents(app: AppHandle, dir: Option<String>) -> Result<Option<String>,
#[tauri::command]
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
@ -695,7 +451,7 @@ const SNAPSHOT_KEEP: usize = 14;
/// before the app writes anything, so it captures yesterday's state.
#[tauri::command]
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 bdir = base.join("backups");
fs::create_dir_all(bdir.join("deleted")).map_err(|e| e.to_string())?;
@ -750,7 +506,7 @@ fn backup_deleted_sheet(
title: String,
contents: 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())?;
let safe: String = title
.chars()
@ -767,12 +523,12 @@ fn backup_deleted_sheet(
safe,
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]
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();
app.opener()
.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
/// Gated like every other `dir` argument: without it this is a probe for the
/// existence of an arbitrary path anywhere on disk.
#[tauri::command]
fn data_dir_has_documents(app: AppHandle, dir: String) -> Result<bool, String> {
Ok(docs_dir(&app, &Some(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()))
fn data_dir_has_documents(dir: String) -> bool {
PathBuf::from(dir).join("documents.json").exists()
}
/// strategy: "move" (copy mine, delete originals), "overwrite" (same, target
@ -808,24 +551,17 @@ fn migrate_data_dir(
new_dir: String,
strategy: String,
) -> Result<(), String> {
// `old_dir` is exactly as untrusted as `new_dir`: on "move" this function
// 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 old = docs_dir(&app, &old_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
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());
if canon_old == canon_new || strategy == "use_existing" {
gate.picked.lock().unwrap().take();
return commit_data_dir(&app, &canon_new);
let canon_new = fs::canonicalize(&new).unwrap_or_else(|_| new.clone());
if canon_old == canon_new {
return Ok(());
}
if strategy == "use_existing" {
return Ok(());
}
let old_docs = old.join("documents.json");
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
if old_docs.exists() {
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 {
for sub in ["backups", "backups/deleted"] {
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();
}
}
}
}
fs::remove_dir_all(old.join("backups")).ok();
}
commit_data_dir(&app, &canon_new)
Ok(())
}
/// Paths the OS actually handed us in a window drop event. The frontend can
/// 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.
/// Used by drag&drop: read a calculation/text file from an absolute path.
#[tauri::command]
fn read_text_file(app: AppHandle, path: String) -> Result<String, String> {
// compare canonicalized, so a symlink or ".." spelling of an allowed 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());
}
fn read_text_file(path: String) -> Result<String, String> {
let p = std::path::PathBuf::from(&path);
let ext = p
.extension()
.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());
}
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());
}
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")
}
/// 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).
#[tauri::command]
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]
@ -1011,7 +717,7 @@ fn main() {
tauri::Builder::default()
.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(content) = read_sheet_file(Path::new(path)) {
if let Ok(content) = fs::read_to_string(path) {
app.emit("open-file", content).ok();
}
}
@ -1029,8 +735,6 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(DroppedFiles::default())
.manage(DataDirGate::default())
.invoke_handler(tauri::generate_handler![
save_file,
load_file,
@ -1040,11 +744,9 @@ fn main() {
backup_deleted_sheet,
open_backups_folder,
data_dir_has_documents,
pick_data_dir,
migrate_data_dir,
fetch_rates,
fetch_historical_rates,
fetch_historical_rates_batch,
fetch_market_data,
load_extensions,
open_extensions_folder,
@ -1057,9 +759,6 @@ fn main() {
])
.setup(|app| {
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 quit = MenuItem::with_id(app, "quit", "Quit Summarum", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
@ -1084,25 +783,15 @@ fn main() {
}
})
.build(app)?;
let _ = handle;
Ok(())
})
.on_window_event(|window, event| match event {
.on_window_event(|window, event| {
// close hides to tray
WindowEvent::CloseRequested { api, .. } => {
if let WindowEvent::CloseRequested { api, .. } = event {
window.hide().ok();
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!())
.expect("error while running Summarum");
@ -1138,137 +827,6 @@ mod tests {
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
#[test]
fn is_sheet_path_matches_extensions() {

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Summarum",
"version": "1.2.9",
"version": "1.2.3",
"identifier": "app.summarum.calc",
"build": {
"beforeDevCommand": "npm run dev",
@ -23,19 +23,19 @@
}
],
"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": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.ico",
"icons/icon.icns"
"icons/icon.ico"
],
"fileAssociations": [
{
@ -44,7 +44,13 @@
"description": "Summarum calculation sheet",
"role": "Editor"
}
]
],
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["English", "Russian"]
}
}
},
"plugins": {
"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();
});
it("should detect dmy (e.g. en-GB)", () => {
expect(detectDateOrder("en-GB")).toBe("dmy");
it("should detect 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)", () => {
expect(detectDateOrder("en-US")).toBe("mdy");
it("should detect 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", () => {
@ -178,32 +204,5 @@ describe("datetime", () => {
expect(isCalendarUnit("minute", 1)).toBe(false);
expect(isCalendarUnit("unit:meter", 1)).toBe(false);
});
it("should handle edge cases for amount", () => {
expect(isCalendarUnit("day", 0)).toBe(true);
expect(isCalendarUnit("day", Number.MAX_SAFE_INTEGER)).toBe(true);
expect(isCalendarUnit("day", Number.MIN_SAFE_INTEGER)).toBe(true);
expect(isCalendarUnit("day", NaN)).toBe(false);
expect(isCalendarUnit("day", Infinity)).toBe(false);
expect(isCalendarUnit("day", -Infinity)).toBe(false);
});
it("should handle edge cases for unitId string format", () => {
expect(isCalendarUnit("", 1)).toBe(false);
expect(isCalendarUnit(":", 1)).toBe(false);
// string.split(":") behavior:
expect(isCalendarUnit("time:month", 1)).toBe(true);
expect(isCalendarUnit(":day", 1)).toBe(true);
expect(isCalendarUnit("day:", 1)).toBe(false);
expect(isCalendarUnit("a:day:c", 1)).toBe(true);
});
it("should return false for case-sensitive mismatch", () => {
expect(isCalendarUnit("Day", 1)).toBe(false);
expect(isCalendarUnit("YEAR", 1)).toBe(false);
expect(isCalendarUnit("MONTH", 1)).toBe(false);
});
});
});

View file

@ -2,8 +2,8 @@
import { DateOrder } from "./types";
/** Does the OS locale write the day or the month first? (31.12 vs 12/31) */
export function detectDateOrder(locale?: string): DateOrder {
const parts = new Intl.DateTimeFormat(locale, { day: "numeric", month: "numeric", year: "numeric" })
export function detectDateOrder(): DateOrder {
const parts = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "numeric", year: "numeric" })
.formatToParts(new Date(2000, 11, 31));
for (const p of parts) {
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
* 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 {
const baseId = baseUnitId(unitId);
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
const d = new Date(ms);
if ((baseId === "day" || baseId === "week") && Number.isInteger(amount)) {
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 {
const baseId = baseUnitId(unitId);
const baseId = unitId.includes(":") ? unitId.split(":")[1] : unitId;
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;
}
function evaluateText(doc: string): (string | null)[] {
return eng.evaluateDocument(doc).map((x) => x.text);
}
describe("edge cases", () => {
it("russian mixed sentence", () => {
expect(calc("потратил 20$ и ещё 5$")).toBe("$25");
@ -49,11 +45,11 @@ describe("edge cases", () => {
expect(calc("hello world")).toBe(null);
});
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"]);
});
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"]);
});
it("big numbers with grouping", () => {
@ -149,7 +145,7 @@ describe("crypto", () => {
describe("regressions", () => {
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"]);
});
it("non-finite results are suppressed", () => {
@ -182,8 +178,8 @@ describe("regressions", () => {
expect(calc("1,234 + 0")).toBe("1,234"); // exactly 3 digits = thousands
});
it("single-letter unit names can be variables", () => {
expect(evaluateText("m = 5\nm * 2")).toEqual(["5", "10"]);
expect(evaluateText("t = 100\nt + 20%")).toEqual(["100", "120"]);
expect(eng.evaluateDocument("m = 5\nm * 2").map((x) => x.text)).toEqual(["5", "10"]);
expect(eng.evaluateDocument("t = 100\nt + 20%").map((x) => x.text)).toEqual(["100", "120"]);
});
it("tiny conversion results show significant digits, not 0", () => {
expect(calc("1 mm in km")).toBe("0.000001 km");
@ -203,7 +199,7 @@ describe("regressions", () => {
expect(calc("4 ^ 50%")).toBe("2");
});
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", () => {
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 { SumEngine } from "./index";
import { qty, EvalError, XRefError } from "./types";
import type { XRefResolution, EvalCtx } from "./evaluator";
import { evaluate } from "./evaluator";
import { formatValue } from "./formatter";
import { qty } from "./types";
import type { XRefResolution } from "./evaluator";
import { formatValue } from "./index";
let eng: SumEngine;
@ -218,7 +217,7 @@ describe("numeral systems", () => {
describe("variables and document context", () => {
it("sequence error recovery", () => {
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");
});
@ -335,13 +334,6 @@ describe("extension API", () => {
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");
});
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)", () => {
eng.addFunction({ id: "zum", phrases: "zum" }, (values) => ({ double: values[0].double + values[1].double }));
expect(calc("zum(2;3)")).toBe("5");
@ -431,24 +423,3 @@ describe("cross-sheet references", () => {
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
// 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 { Registry } from "./registry";
import {
resolveZone,
startOfToday,
addToDate,
isCalendarUnit,
} from "./datetime";
import { resolveZone, startOfToday, addToDate, isCalendarUnit } from "./datetime";
export interface LineCtx {
/** values of lines above (null = no result) */
@ -30,11 +25,10 @@ export interface EvalCtx {
resolveXRef?: XRefResolver;
}
export type XRefResolution =
| { ok: true; value: Value }
| { ok: false; reason: string };
export type XRefResolution = { ok: true; value: Value } | { ok: false; reason: string };
export type XRefResolver = (sheet: string, key: string) => XRefResolution;
const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
const E = new Decimal("2.71828182845904523536028747135266249775724709369995");
export function evaluate(node: Node, ctx: EvalCtx): Value {
@ -42,34 +36,71 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "num":
return qty(node.v, null, node.repr);
case "const":
return evaluateConst(node);
case "var":
case "xref":
case "unknown":
return evaluateRef(node, ctx);
switch (node.name) {
case "pi": return qty(PI);
case "e": return qty(E);
case "half": return qty(new Decimal("0.5"));
case "onehalf": return qty(new Decimal("1.5"));
}
case "var": {
const v = ctx.vars.get(node.name);
if (!v) throw new EvalError(`unknown variable ${node.name}`);
return v;
}
case "date":
return evalDateWord(node.word);
case "datelit":
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":
return evalGoalSeek(node.lhs, node.rhs, ctx);
case "agg":
return evalAgg(node.name, ctx);
case "neg":
case "pct":
case "fact":
case "scale":
return evaluateMath(node, ctx);
case "unit":
case "curr":
return evaluateUnitMod(node, ctx);
case "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");
}
case "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");
}
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":
return evalBin(
node.op,
evaluate(node.l, ctx),
evaluate(node.r, ctx),
ctx,
);
return evalBin(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx), ctx);
case "bit":
return evalBit(node.op, evaluate(node.l, ctx), evaluate(node.r, ctx));
case "pctop":
@ -77,13 +108,30 @@ export function evaluate(node: Node, ctx: EvalCtx): Value {
case "conv":
return evalConv(evaluate(node.x, ctx), node.target, ctx);
case "call":
return evalCall(
node.name,
node.args.map((a) => evaluate(a, ctx)),
ctx,
);
case "seq":
return evaluateSeq(node, ctx);
return evalCall(node.name, node.args.map((a) => evaluate(a, ctx)), ctx);
case "seq": {
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;
}
}
}
@ -95,7 +143,7 @@ function asScalar(v: Value): Decimal {
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.unit && v.unit.dimension === unit.dimension) return convertQ(v, unit);
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 {
if (!q.unit) return { ...q, unit };
if (q.unit.dimension !== unit.dimension)
throw new EvalError("dimension mismatch");
if (q.unit.dimension !== unit.dimension) throw new EvalError("dimension mismatch");
return qty(fromBase(toBase(q), unit), unit);
}
@ -139,52 +186,36 @@ function addQ(a: Quantity, b: Quantity): Quantity {
function numericAdd(a: Quantity, b: Quantity, sign: 1 | -1): Quantity {
if (a.unit && b.unit) {
if (a.unit.dimension !== b.unit.dimension)
throw new EvalError("dimension mismatch");
if (a.unit.dimension !== b.unit.dimension) throw new EvalError("dimension mismatch");
if (a.unit.offset || b.unit.offset) {
// temperatures: operate in the left unit's scale
const bInA = convertQ(b, a.unit);
return qty(
sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value),
a.unit,
);
return qty(sign === 1 ? a.value.add(bInA.value) : a.value.sub(bInA.value), a.unit);
}
const base =
sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
const base = sign === 1 ? toBase(a).add(toBase(b)) : toBase(a).sub(toBase(b));
return qty(fromBase(base, a.unit), a.unit);
}
const unit = a.unit ?? b.unit ?? null;
return qty(sign === 1 ? a.value.add(b.value) : a.value.sub(b.value), unit);
}
function evalPercentArith(
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
l: Value,
r: Value,
): Value {
function evalPercentArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value): Value {
if (l.kind === "percent" && r.kind === "percent") {
switch (op) {
case "plus":
return pct(l.value.add(r.value));
case "minus":
return pct(l.value.sub(r.value));
case "mul":
return pct(l.value.mul(r.value).div(100));
case "div":
return qty(l.value.div(r.value));
default:
throw new EvalError("bad percent op");
case "plus": return pct(l.value.add(r.value));
case "minus": return pct(l.value.sub(r.value));
case "mul": return pct(l.value.mul(r.value).div(100));
case "div": return qty(l.value.div(r.value));
default: throw new EvalError("bad percent op");
}
}
const q = (l.kind === "quantity" ? l : r) as Quantity;
const p = (
l.kind === "percent" ? l : (r as { kind: "percent"; value: Decimal })
).value;
const p = (l.kind === "percent" ? l : r as { kind: "percent"; value: Decimal }).value;
switch (op) {
case "plus": return qty(q.value.mul(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":
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 "div":
if (l.kind === "percent") throw new EvalError("percent div number");
@ -194,22 +225,14 @@ function evalPercentArith(
return l.kind === "percent"
? qty(p.div(100).pow(q.value))
: qty(q.value.pow(p.div(100)), q.unit);
default:
throw new EvalError("bad percent op");
default: throw new EvalError("bad percent op");
}
}
function evalQuantityArith(
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
a: Quantity,
b: Quantity,
ctx: EvalCtx,
): Value {
function evalQuantityArith(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", a: Quantity, b: Quantity, ctx: EvalCtx): Value {
switch (op) {
case "plus":
return numericAdd(a, b, 1);
case "minus":
return numericAdd(a, b, -1);
case "plus": return numericAdd(a, b, 1);
case "minus": return numericAdd(a, b, -1);
case "mul": {
if (a.unit && b.unit) {
const da = a.unit.dimension;
@ -224,8 +247,7 @@ function evalQuantityArith(
if (u) return qty(baseProduct.div(u.ratio), u);
}
// same dimension: "2 kg * 500 g" scales in the left unit, not 2*500
if (da === db)
return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
if (da === db) return qty(a.value.mul(convertQ(b, a.unit).value), a.unit);
return qty(a.value.mul(b.value), a.unit);
}
return qty(a.value.mul(b.value), a.unit ?? b.unit ?? null);
@ -244,28 +266,16 @@ function evalQuantityArith(
case "pow": {
const result = a.value.pow(b.value);
if (a.unit?.dimension === "length" && !b.unit) {
const id = b.value.eq(2)
? `sq:${a.unit.id}`
: b.value.eq(3)
? `cb:${a.unit.id}`
: null;
const id = b.value.eq(2) ? `sq:${a.unit.id}` : b.value.eq(3) ? `cb:${a.unit.id}` : null;
const u = id ? ctx.reg.unitsById.get(id) : null;
if (u) return qty(result, u);
}
return qty(
result,
b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null,
);
return qty(result, b.unit ? null : a.unit && b.value.eq(1) ? a.unit : null);
}
}
}
function evalBin(
op: "plus" | "minus" | "mul" | "div" | "mod" | "pow",
l: Value,
r: Value,
ctx: EvalCtx,
): Value {
function evalBin(op: "plus" | "minus" | "mul" | "div" | "mod" | "pow", l: Value, r: Value, ctx: EvalCtx): Value {
if (l.kind === "date" || r.kind === "date") {
return evalDateArith(op, l, r);
}
@ -278,43 +288,24 @@ function evalBin(
}
function evalBit(op: string, l: Value, r: Value): Value {
if (l.kind !== "quantity" || r.kind !== "quantity")
throw new EvalError("bitwise needs integers");
if (!l.value.isFinite() || !r.value.isFinite())
throw new EvalError("bitwise needs finite integers");
if (l.kind !== "quantity" || r.kind !== "quantity") throw new EvalError("bitwise needs integers");
if (!l.value.isFinite() || !r.value.isFinite()) throw new EvalError("bitwise needs finite integers");
const a = BigInt(l.value.toDecimalPlaces(0).toFixed(0));
const b = BigInt(r.value.toDecimalPlaces(0).toFixed(0));
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n))
throw new EvalError("bad shift");
if ((op === "shl" || op === "shr") && (b < 0n || b > 256n)) throw new EvalError("bad shift");
let out: bigint;
switch (op) {
case "band":
out = a & b;
break;
case "bor":
out = a | b;
break;
case "bxor":
out = a ^ b;
break;
case "shl":
out = a << b;
break;
case "shr":
out = a >> b;
break;
default:
throw new EvalError("bad bit op");
case "band": out = a & b; break;
case "bor": out = a | b; break;
case "bxor": out = a ^ b; break;
case "shl": out = a << b; break;
case "shr": out = a >> b; break;
default: throw new EvalError("bad bit op");
}
const repr = l.repr !== "decimal" ? l.repr : r.repr;
return qty(new Decimal(out.toString()), null, repr);
}
/** 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 {
const rq = r.kind === "quantity" ? r : 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;
if (op === "of" || op === "off" || op === "on") {
const p = asPercentValue(l);
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
const factor =
op === "of" ? p.div(100)
: op === "off" ? DEC_ONE.sub(p.div(100))
: DEC_ONE.add(p.div(100));
: op === "off" ? new Decimal(1).sub(p.div(100))
: new Decimal(1).add(p.div(100));
// a percent of a percent stays a percent: 50% of 50% = 25%
if (r.kind === "percent") return pct(r.value.mul(factor));
return qty(rv.mul(factor), unit);
@ -336,21 +327,20 @@ function evalPctOp(op: string, l: Value, r: Value): Value {
if (rv.isZero()) throw new EvalError("division by zero");
switch (op) {
case "as_pct_of": return pct(lv.div(rv).mul(100));
case "as_pct_off": return pct(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));
}
}
// "20% of what is 30"
const p = asPercentValue(l);
const p = l.kind === "percent" ? l.value : asScalar(l).mul(100);
switch (op) {
case "of_what_is":
return qty(rv.div(p.div(100)), unit);
case "of_what_is": return qty(rv.div(p.div(100)), unit);
case "off_what_is": {
const d = DEC_ONE.sub(p.div(100));
const d = new Decimal(1).sub(p.div(100));
if (d.isZero()) throw new EvalError("division by zero");
return qty(rv.div(d), unit);
}
case "on_what_is": return qty(rv.div(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}`);
}
@ -359,8 +349,7 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
switch (target.type) {
case "unit": {
if (v.kind === "percent") return pct(v.value); // "x in %" — keep
if (v.kind !== "quantity")
throw new EvalError("cannot convert date to unit");
if (v.kind !== "quantity") throw new EvalError("cannot convert date to unit");
if (!v.unit) return qty(v.value, target.unit);
return convertQ(v, target.unit);
}
@ -369,17 +358,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
if (target.onDate) {
const dateRates = ctx.historicalRates?.get(target.onDate);
const rate = dateRates?.get(target.code);
if (rate === undefined)
throw new EvalError(
`no historical rate for ${target.code} on ${target.onDate}`,
);
if (rate === undefined) throw new EvalError(`no historical rate for ${target.code} on ${target.onDate}`);
unit = ctx.reg.makeCurrencyUnitFromRate(target.code, new Decimal(rate));
} else {
unit = ctx.reg.makeCurrencyUnit(target.code);
if (!unit) throw new EvalError(`no rate for ${target.code}`);
}
if (v.kind !== "quantity")
throw new EvalError("cannot convert to currency");
if (v.kind !== "quantity") throw new EvalError("cannot convert to currency");
if (!v.unit) return qty(v.value, unit);
return convertQ(v, unit);
}
@ -398,17 +383,13 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
return { ...v, timeZone: zone };
}
case "unix": {
if (v.kind === "date")
return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
if (v.kind === "date") return qty(new Decimal(v.ms).div(1000).floor(), null, "plain");
throw new EvalError("unix needs a date");
}
case "todate": {
if (v.kind !== "quantity" || v.unit)
throw new EvalError("date needs a plain number");
if (v.kind !== "quantity" || v.unit) throw new EvalError("date needs a plain number");
// heuristics: > 1e12 means milliseconds, else seconds
const ms = v.value.gt(1e12)
? v.value.toNumber()
: v.value.mul(1000).toNumber();
const ms = v.value.gt(1e12) ? v.value.toNumber() : v.value.mul(1000).toNumber();
return { kind: "date", ms, hasTime: true };
}
}
@ -416,25 +397,12 @@ function evalConv(v: Value, target: ConvTarget, ctx: EvalCtx): Value {
function evalDateWord(word: string): Value {
switch (word) {
case "today":
return { kind: "date", ms: startOfToday(), hasTime: false };
case "tomorrow": {
const d = new Date(startOfToday());
d.setDate(d.getDate() + 1);
return { kind: "date", ms: d.getTime(), hasTime: false };
}
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}`);
case "today": return { kind: "date", ms: startOfToday(), hasTime: false };
case "tomorrow": return { kind: "date", ms: startOfToday() + 86400_000, hasTime: false };
case "yesterday": return { kind: "date", ms: startOfToday() - 86400_000, 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 (op !== "minus") throw new EvalError("dates only subtract");
// midnight-to-midnight differences count calendar days (robust across DST)
const days =
!l.hasTime && !r.hasTime
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
: new Decimal(l.ms - r.ms).div(86400_000);
return qty(days, {
id: "day",
dimension: "time",
ratio: new Decimal(86400),
format: "day",
});
const days = !l.hasTime && !r.hasTime
? new Decimal(Math.round((l.ms - r.ms) / 86400_000))
: new Decimal(l.ms - r.ms).div(86400_000);
return qty(days, { id: "day", dimension: "time", ratio: new Decimal(86400), format: "day" });
}
const date = (l.kind === "date" ? l : r) as Extract<Value, { kind: "date" }>;
const span = l.kind === "date" ? r : l;
@ -459,8 +421,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
throw new EvalError("date arithmetic needs a time span");
}
if (op !== "plus" && op !== "minus") throw new EvalError("bad date op");
if (op === "minus" && l.kind !== "date")
throw new EvalError("cannot subtract date from number");
if (op === "minus" && l.kind !== "date") throw new EvalError("cannot subtract date from number");
const amount = span.value.toNumber() * (op === "minus" ? -1 : 1);
let ms: number;
let hasTime = date.hasTime;
@ -482,130 +443,82 @@ function evalGoalSeek(lhsNode: Node, rhsNode: Node, ctx: EvalCtx): Value {
const innerCtx: EvalCtx = { ...ctx, vars };
const lv = evaluate(lhsNode, innerCtx);
const rv = evaluate(rhsNode, innerCtx);
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : DEC_ZERO;
const toD = (v: Value) => v.kind === "quantity" ? toBase(v) : new Decimal(0);
return toD(lv).minus(toD(rv));
};
const safeEval = (x: Decimal): Decimal | null => {
try {
return evalF(x);
} catch {
return null;
}
};
const f0 = safeEval(DEC_ZERO);
const f1 = safeEval(DEC_ONE);
if (f0 !== null && f1 !== null) {
const slope = f1.minus(f0);
if (!slope.isZero()) {
const root = f0.neg().div(slope);
const fRoot = safeEval(root);
if (fRoot !== null && fRoot.abs().lt(new Decimal("1e-9")))
return qty(root);
}
// Linear probe: f(0) and f(1) give slope → exact root for linear expressions
const f0 = evalF(new Decimal(0));
const f1 = evalF(new Decimal(1));
const slope = f1.minus(f0);
if (!slope.isZero()) {
const root = f0.neg().div(slope);
if (evalF(root).abs().lt(new Decimal("1e-9"))) return qty(root);
}
// Bisection fallback for nonlinear cases
let lo = new Decimal(-1e9);
let hi = new Decimal(1e9);
let flo = safeEval(lo);
let fhi = safeEval(hi);
if (flo === null || fhi === null || flo.mul(fhi).gt(0)) {
const probes = [
1, 2, 5, 10, 100, 1000, 10000, -1, -2, -5, -10, -100, -1000, -10000,
];
let found = false;
for (const p of probes) {
const pDec = new Decimal(p);
const fp = safeEval(pDec);
if (fp === null) continue;
if (flo === null) {
lo = pDec;
flo = fp;
} else if (fhi === null) {
hi = pDec;
fhi = fp;
} else if (flo.mul(fp).lte(0)) {
hi = pDec;
fhi = fp;
} else if (fhi.mul(fp).lte(0)) {
lo = pDec;
flo = fp;
} else continue;
if (flo !== null && fhi !== null && flo.mul(fhi).lte(0)) {
found = true;
break;
}
}
if (!found) throw new EvalError("no solution");
}
let flo = evalF(lo);
let fhi = evalF(hi);
if (flo.mul(fhi).gt(0)) throw new EvalError("no solution");
for (let i = 0; i < 100; i++) {
const mid = lo.plus(hi).div(2);
if (hi.minus(lo).abs().lt(new Decimal("1e-10"))) return qty(mid);
const fm = safeEval(mid);
if (fm === null) {
lo = mid.plus("1e-9");
continue;
}
if (flo!.mul(fm).lte(0)) {
hi = mid;
fhi = fm;
} else {
lo = mid;
flo = fm;
}
const fm = evalF(mid);
if (flo.mul(fm).lte(0)) { hi = mid; fhi = fm; }
else { lo = mid; flo = fm; }
}
return qty(lo.plus(hi).div(2));
}
/** Lines whose unit shares the first line's dimension (mixed-dimension blocks skip the rest). */
function filterSameDimension(block: Quantity[]): Quantity[] {
const refDim = block[0].unit?.dimension ?? null;
return block.filter((q) => (q.unit?.dimension ?? null) === refDim);
}
/** Adds compatible lines left to right, silently skipping ones with an incompatible dimension. */
function reduceCompatible(block: Quantity[]): {
acc: Quantity;
counted: number;
} {
let acc = block[0];
let counted = 1;
for (let i = 1; i < block.length; i++) {
try {
acc = numericAdd(acc, block[i], 1);
counted++;
} catch {
// skip lines with incompatible dimensions
}
}
return { acc, counted };
}
const AGG_FUNCS: Record<string, (block: Quantity[]) => Value> = {
count: (block) => qty(new Decimal(block.length)),
chart: (block) => {
const compatible = filterSameDimension(block);
if (compatible.length < 2)
throw new EvalError("need at least 2 values for chart");
const refDim = block[0].unit?.dimension ?? null;
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 {
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);
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 counted = 1;
for (let i = 1; i < block.length; i++) {
try {
acc = numericAdd(acc, block[i], 1);
counted++;
} catch {
// skip lines with incompatible dimensions
}
}
return qty(acc.value.div(counted), acc.unit);
}
};
function getPrevResult(ctx: EvalCtx): Value {
@ -634,10 +547,7 @@ function getAggBlock(ctx: EvalCtx): Quantity[] {
return block;
}
function evalAgg(
name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart",
ctx: EvalCtx,
): Value {
function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value {
if (name === "prev") return getPrevResult(ctx);
const block = getAggBlock(ctx);
const func = AGG_FUNCS[name];
@ -645,80 +555,6 @@ function evalAgg(
return func(block);
}
const angleArg = (x: Value): Decimal => {
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const BUILTIN_FUNCS: Record<string, (x: Value, ctx: EvalCtx) => Value> = {
sqrt: (x, ctx) => {
if (
x.kind === "quantity" &&
x.unit?.dimension === "area" &&
x.unit.id.startsWith("sq:")
) {
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
if (base) return qty(x.value.sqrt(), base);
}
return qty(asScalar(x).sqrt());
},
cbrt: (x) => qty(asScalar(x).cbrt()),
sin: (x) => qty(Decimal.sin(angleArg(x))),
cos: (x) => qty(Decimal.cos(angleArg(x))),
tan: (x) => qty(Decimal.tan(angleArg(x))),
cot: (x) => qty(DEC_ONE.div(Decimal.tan(angleArg(x)))),
asin: (x) => qty(Decimal.asin(asScalar(x))),
arcsin: (x) => qty(Decimal.asin(asScalar(x))),
acos: (x) => qty(Decimal.acos(asScalar(x))),
arccos: (x) => qty(Decimal.acos(asScalar(x))),
atan: (x) => qty(Decimal.atan(asScalar(x))),
arctan: (x) => qty(Decimal.atan(asScalar(x))),
sinh: (x) => qty(Decimal.sinh(asScalar(x))),
cosh: (x) => qty(Decimal.cosh(asScalar(x))),
tanh: (x) => qty(Decimal.tanh(asScalar(x))),
ln: (x) => qty(Decimal.ln(asScalar(x))),
lg: (x) => qty(Decimal.log10(asScalar(x))),
log: (x) => qty(Decimal.log10(asScalar(x))),
log2: (x) => qty(Decimal.log2(asScalar(x))),
exp: (x) => qty(Decimal.exp(asScalar(x))),
abs: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.abs() }
: qty(asScalar(x).abs()),
round: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.round() }
: qty(asScalar(x).round()),
ceil: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.ceil() }
: qty(asScalar(x).ceil()),
floor: (x) =>
x.kind === "quantity"
? { ...x, value: x.value.floor() }
: qty(asScalar(x).floor()),
fact: (x) => qty(factorial(asScalar(x))),
factorial: (x) => qty(factorial(asScalar(x))),
until: (x) => {
if (x.kind !== "date") throw new EvalError("until needs a date");
return evalDateArith("minus", x, {
kind: "date",
ms: startOfToday(),
hasTime: false,
});
},
since: (x) => {
if (x.kind !== "date") throw new EvalError("since needs a date");
return evalDateArith(
"minus",
{ kind: "date", ms: startOfToday(), hasTime: false },
x,
);
},
};
function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
const custom = ctx.reg.customFuncs.get(name);
if (custom) return custom(args);
@ -727,27 +563,63 @@ function evalCall(name: string, args: Value[], ctx: EvalCtx): Value {
const x = args[0];
if (!x) throw new EvalError(`${name} needs an argument`);
const func = BUILTIN_FUNCS[name];
if (!func) throw new EvalError(`unknown function ${name}`);
return func(x, ctx);
// trig works on angles: degrees convert to radians
const angleArg = (): Decimal => {
if (x.kind === "quantity" && x.unit?.dimension === "angle") {
return x.value.mul(x.unit.ratio);
}
return asScalar(x);
};
const n = () => asScalar(x);
switch (name) {
case "sqrt": {
if (x.kind === "quantity" && x.unit?.dimension === "area" && x.unit.id.startsWith("sq:")) {
const base = ctx.reg.unitsById.get(x.unit.id.slice(3));
if (base) return qty(x.value.sqrt(), base);
}
return qty(n().sqrt());
}
case "cbrt": return qty(n().cbrt());
case "sin": return qty(Decimal.sin(angleArg()));
case "cos": return qty(Decimal.cos(angleArg()));
case "tan": return qty(Decimal.tan(angleArg()));
case "cot": return qty(new Decimal(1).div(Decimal.tan(angleArg())));
case "asin": case "arcsin": return qty(Decimal.asin(n()));
case "acos": case "arccos": return qty(Decimal.acos(n()));
case "atan": case "arctan": return qty(Decimal.atan(n()));
case "sinh": return qty(Decimal.sinh(n()));
case "cosh": return qty(Decimal.cosh(n()));
case "tanh": return qty(Decimal.tanh(n()));
case "ln": return qty(Decimal.ln(n()));
case "lg": case "log": return qty(Decimal.log10(n()));
case "log2": return qty(Decimal.log2(n()));
case "exp": return qty(Decimal.exp(n()));
case "abs": return x.kind === "quantity" ? { ...x, value: x.value.abs() } : qty(n().abs());
case "round": return x.kind === "quantity" ? { ...x, value: x.value.round() } : qty(n().round());
case "ceil": return x.kind === "quantity" ? { ...x, value: x.value.ceil() } : qty(n().ceil());
case "floor": return x.kind === "quantity" ? { ...x, value: x.value.floor() } : qty(n().floor());
case "fact": case "factorial": return qty(factorial(n()));
case "until": {
if (x.kind !== "date") throw new EvalError("until needs a date");
return evalDateArith("minus", x, { kind: "date", ms: startOfToday(), hasTime: false });
}
case "since": {
if (x.kind !== "date") throw new EvalError("since needs a date");
return evalDateArith("minus", { kind: "date", ms: startOfToday(), hasTime: false }, x);
}
default: throw new EvalError(`unknown function ${name}`);
}
}
// Native float, not Decimal: this is a PRNG (mulberry32/strHash bit-twiddling
// over 32-bit ints), not arithmetic on user quantities — decimal.js has no
// bitwise ops and would add nothing but overhead here.
function evalRandom(args: Value[], ctx: EvalCtx): Value {
const seed = strHash(ctx.line.lineText) ^ (ctx.line.index * 0x9e3779b9);
const rand = mulberry32(seed);
if (args.length === 0) return qty(new Decimal(rand));
const toNum = (v: Value) => (v.kind === "quantity" ? v.value.toNumber() : 0);
const [lo, hi] =
args.length === 1
? [0, toNum(args[0]!)]
: [toNum(args[0]!), toNum(args[1]!)];
const toNum = (v: Value) => v.kind === "quantity" ? v.value.toNumber() : 0;
const [lo, hi] = args.length === 1 ? [0, toNum(args[0]!)] : [toNum(args[0]!), toNum(args[1]!)];
const isInt = Number.isInteger(lo) && Number.isInteger(hi);
const raw = isInt
? Math.floor(rand * (hi - lo + 1)) + lo
: rand * (hi - lo) + lo;
const raw = isInt ? Math.floor(rand * (hi - lo + 1)) + lo : rand * (hi - lo) + lo;
return qty(new Decimal(raw));
}
@ -760,121 +632,15 @@ function strHash(s: string): number {
}
function mulberry32(seed: number): number {
seed = (seed + 0x6d2b79f5) | 0;
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function factorial(n: Decimal): Decimal {
if (!n.isInteger() || n.isNegative() || n.gt(300)) throw new EvalError("bad factorial");
let acc = DEC_ONE;
let acc = new Decimal(1);
for (let i = 2; i <= n.toNumber(); i++) acc = acc.mul(i);
return acc;
}
function evaluateConst(node: {
k: "const";
name: "pi" | "e" | "half" | "onehalf";
}): Value {
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
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 neg = int.isNegative();
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". */
// 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 {
const neg = v.isNegative();
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
// extension API (numi.addUnit / addFunction / setVariable).
import { DateOrder, Decimal, EngineSettings, EvalError, Quantity, Unit, Value, XRefError, defaultSettings, qty } from "./types";
import { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer";
import { parseLine, ParsedLine } from "./parser";
import { parseLine } from "./parser";
import { evaluate, EvalCtx, XRefResolver, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter";
import { detectDateOrder } from "./datetime";
@ -119,42 +118,32 @@ export class SumEngine {
continue;
}
// tokenize/parseLine/evaluate all run under one try/catch: pathological
// input (e.g. stack-overflowing nested parens in the parser) must null
// out this one line, not throw past evaluateDocument and kill the rest.
let tokens: Token[] = [];
let parsed: ParsedLine = { expr: null };
const tokens = tokenize(line, this.reg, this.dateOrder);
const knownVars = new Set(vars.keys());
const parsed = parseLine(tokens, knownVars, line);
let value: Value | null = null;
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) {
const ctx: EvalCtx = {
reg: this.reg,
vars,
line: { lineValues, lineKinds, index: i, lineText: rawLine },
historicalRates: this.historicalRates,
resolveXRef,
};
if (parsed.expr) {
const ctx: EvalCtx = {
reg: this.reg,
vars,
line: { lineValues, lineKinds, index: i, lineText: rawLine },
historicalRates: this.historicalRates,
resolveXRef,
};
try {
value = evaluate(parsed.expr, ctx);
// a line without a result beats a dead sheet — swallow everything,
// including extension bugs and BigInt conversion errors
} catch (e) {
if (e instanceof XRefError) lineError = e.message;
else if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
value = null;
}
} catch (e) {
if (e instanceof XRefError) {
lineError = e.message;
} else if (e instanceof EvalError) {
// Expected evaluation failure; remain silent
} else {
// Capture unexpected execution anomalies like BigInt or Range errors
lineError = e instanceof Error ? e.message : String(e);
logger.warn("evaluate failed:", e);
}
// a line without a result beats a dead sheet.
value = null;
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
}
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
lineValues.push(value);
lineKinds.push("normal");
@ -219,7 +208,6 @@ export class SumEngine {
addUnit(spec: ExtensionUnitSpec): void {
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(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 = {
id: spec.id,
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
// a conversion operator (potential timezone names).
const filtered: Token[] = [];
@ -93,10 +81,7 @@ function filterNoiseTokens(toks: Token[], knownVars: Set<string>): Token[] {
afterConv = tk.t === "conv";
filtered.push(tk);
}
return filtered;
}
function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | null {
// Goal seek: `? * 1.2 = 1000` → find x where lhs(x) = rhs(x)
const hasUnknown = filtered.some((tk) => tk.t === "unknown");
if (hasUnknown) {
@ -104,23 +89,14 @@ function tryParseGoalSeek(filtered: Token[], knownVars: Set<string>): Node | nul
if (assignIdx >= 0) {
const lhs = new Parser(filtered.slice(0, assignIdx), knownVars).parseSeq();
const rhs = new Parser(filtered.slice(assignIdx + 1), knownVars).parseSeq();
if (lhs && rhs) return { k: "goalseek", lhs, rhs };
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 {
private i = 0;
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) {
this.i++;
const r = this.parseExpr(PREC.ADD);
const r = this.parseAdd();
if (r) {
items = [{ k: "pctop", op: tk.op, l: mk(), r }];
continue;
@ -165,7 +141,7 @@ class Parser {
if (tk.t === "op" && items.length > 0) {
const save = 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) {
items = [{ k: "bin", op: tk.op, l: mk(), r }];
continue;
@ -176,7 +152,7 @@ class Parser {
if (tk.t === "bitop" && items.length > 0) {
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD);
const r = this.parseAdd();
if (r) {
items = [{ k: "bit", op: tk.op, l: mk(), r }];
continue;
@ -185,7 +161,7 @@ class Parser {
continue;
}
const before = this.i;
const node = this.parseExpr(PREC.BIT);
const node = this.parseBit();
if (node) items.push(node);
if (this.i === before) this.i++; // skip unparsable token
}
@ -193,161 +169,52 @@ class Parser {
return mk();
}
/**
* Pratt parser implementation for expressions.
*/
private parseExpr(minPrec: number = 0): Node | null {
let tk = this.peek();
if (!tk) return null;
let lhs: Node | null = null;
if (tk.t === "op" && tk.op === "minus") {
/** bitwise level (one flat precedence, left-assoc): & | xor << >> */
private parseBit(): Node | null {
let l = this.parseAdd();
if (!l) return null;
for (;;) {
const tk = this.peek();
if (tk?.t !== "bitop") break;
const save = this.i;
this.i++;
const x = this.parseExpr(PREC.UNARY);
if (!x) return null;
lhs = { k: "neg", x };
} else if (tk.t === "op" && tk.op === "plus") {
this.i++;
lhs = this.parseExpr(PREC.UNARY);
if (!lhs) return null;
} else {
lhs = this.parsePrimary();
}
if (!lhs) return null;
loop: for (;;) {
tk = this.peek();
if (!tk) break;
switch (tk.t) {
case "percent": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "pct", x: lhs };
continue loop;
}
case "bang": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "fact", x: lhs };
continue loop;
}
case "unit": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "unit", x: lhs, unit: tk.unit };
continue loop;
}
case "currency": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "curr", x: lhs, code: tk.code };
continue loop;
}
case "scale": {
if (PREC.POSTFIX < minPrec) break loop;
this.i++;
lhs = { k: "scale", x: lhs, mult: tk.mult, label: "" };
continue loop;
}
case "op": {
switch (tk.op) {
case "pow": {
if (PREC.POW < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.POW);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: "pow", l: lhs, r };
continue loop;
}
case "mul":
case "div":
case "mod": {
if (PREC.MUL < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: tk.op, l: lhs, r };
continue loop;
}
case "plus":
case "minus": {
if (PREC.ADD < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: tk.op, l: lhs, r };
continue loop;
}
}
break loop;
}
case "pctop": {
if (tk.op === "of" || tk.op === "off" || tk.op === "on") {
if (!(lhs.k === "pct" || lhs.k === "var" || lhs.k === "pctop")) {
this.i++;
continue loop;
}
if (PREC.MUL < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "pctop", op: tk.op, l: lhs, r };
continue loop;
}
if (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is")) {
if (PREC.CONV < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.ADD);
if (!r) { this.i = save; break loop; }
lhs = { k: "pctop", op: tk.op, l: lhs, r };
continue loop;
}
break loop;
}
case "lparen":
case "const":
case "func":
case "word": {
if (tk.t === "word" && !this.vars.has(tk.raw)) break loop;
if (PREC.MUL < minPrec) break loop;
const save = this.i;
const r = this.parseExpr(PREC.MUL + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bin", op: "mul", l: lhs, r };
continue loop;
}
case "bitop": {
if (PREC.BIT < minPrec) break loop;
const save = this.i;
this.i++;
const r = this.parseExpr(PREC.BIT + 1);
if (!r) { this.i = save; break loop; }
lhs = { k: "bit", op: tk.op, l: lhs, r };
continue loop;
}
case "conv": {
if (PREC.CONV < minPrec) break loop;
const save = this.i;
this.i++;
const target = this.parseTarget();
if (!target) { this.i = save; break loop; }
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
continue loop;
}
const r = this.parseAdd();
if (!r) {
this.i = save;
break;
}
break;
l = { k: "bit", op: tk.op, l, r };
}
return lhs;
return l;
}
/** conversion level: `x in cm`, `x as a % of y`, `x of what is y` */
private parseConv(): Node | null {
let l = this.parseBit();
if (!l) return null;
for (;;) {
const tk = this.peek();
if (tk?.t === "conv") {
const save = this.i;
this.i++;
const target = this.parseTarget();
if (!target) {
this.i = save;
break;
}
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`. */
private tryHistoricalDate(target: ConvTarget): ConvTarget {
if (target.type !== "currency") return target;
@ -413,6 +280,122 @@ class Parser {
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 {
const tk = this.peek();
if (!tk) return null;
@ -448,7 +431,7 @@ class Parser {
this.i++;
const args: Node[] = [];
for (;;) {
const arg = this.parseExpr(PREC.CONV);
const arg = this.parseConv();
if (arg) args.push(arg);
const nx = this.peek();
if (nx?.t === "semicolon") {
@ -469,12 +452,12 @@ class Parser {
// `sqrt 16` / `square root of 16` — the "of" is decorative
const nx = this.peek();
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;
}
case "lparen": {
this.i++;
const inner = this.parseExpr(PREC.CONV);
const inner = this.parseConv();
if (this.peek()?.t === "rparen") this.i++;
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 { EXTRA_UNITS, CRYPTO } from "./extraunits";
/** Operators for percentage calculations. */
export type PctOp =
| "of" | "off" | "on"
| "as_pct_of" | "as_pct_off" | "as_pct_on"
@ -178,29 +177,29 @@ function registerCoreVocab(reg: Registry): void {
["minus", { t: "op", op: "minus" }],
["multiply", { t: "op", op: "mul" }],
["divide", { t: "op", op: "div" }],
["conversion", { t: "conv" }],
["assignment", { t: "assign" }],
["sum", { t: "agg", name: "sum" }],
["avg", { t: "agg", name: "avg" }],
["prev", { t: "agg", name: "prev" }],
["count", { t: "agg", name: "count" }],
["min", { t: "agg", name: "min" }],
["max", { t: "agg", name: "max" }],
["product", { t: "agg", name: "product" }],
["chart", { t: "agg", name: "chart" }],
["sqrt", { t: "func", name: "sqrt" }],
["cbrt", { t: "func", name: "cbrt" }],
["abs", { t: "func", name: "abs" }],
["ln", { t: "func", name: "ln" }],
["log", { t: "func", name: "log" }],
["round", { t: "func", name: "round" }],
["random", { t: "func", name: "random" }],
["until", { t: "func", name: "until" }],
["since", { t: "func", name: "since" }],
];
for (const [key, payload] of ops) {
for (const v of vocab.variants("Operations", `${key}.variants`)) reg.addPhrase(v, payload, { caseSensitive: false });
}
for (const v of vocab.variants("Operations", "conversion.variants")) reg.addPhrase(v, { t: "conv" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "assignment.variants")) reg.addPhrase(v, { t: "assign" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "sum.variants")) reg.addPhrase(v, { t: "agg", name: "sum" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "avg.variants")) reg.addPhrase(v, { t: "agg", name: "avg" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "prev.variants")) reg.addPhrase(v, { t: "agg", name: "prev" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "count.variants")) reg.addPhrase(v, { t: "agg", name: "count" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "chart.variants")) reg.addPhrase(v, { t: "agg", name: "chart" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "ln.variants")) reg.addPhrase(v, { t: "func", name: "ln" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "log.variants")) reg.addPhrase(v, { t: "func", name: "log" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "round.variants")) reg.addPhrase(v, { t: "func", name: "round" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "random.variants")) reg.addPhrase(v, { t: "func", name: "random" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "until.variants")) reg.addPhrase(v, { t: "func", name: "until" }, { caseSensitive: false });
for (const v of vocab.variants("Operations", "since.variants")) reg.addPhrase(v, { t: "func", name: "since" }, { caseSensitive: false });
reg.addPhrase("mod", { t: "op", op: "mod" }, { caseSensitive: false });
reg.addPhrase("modulo", { t: "op", op: "mod" }, { caseSensitive: false });

View file

@ -115,7 +115,7 @@ export function tokenize(line: string, reg: Registry, dateOrder: DateOrder = "dm
tokens.push({ t: "word", raw: lx.raw, ...span });
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;
});
}
/**
* "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 };
/** Shared here (not in evaluator.ts) so unitdata.ts can use it too without a cycle. */
export const PI = new Decimal("3.14159265358979323846264338327950288419716939937510");
export const DEC_ZERO = new Decimal(0);
export const DEC_ONE = new Decimal(1);
export type Dimension =
| "scalar"
| "length"

View file

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

View file

@ -169,13 +169,11 @@ export const EN: VocabLocale = {
"KES.format": "%@ KSh",
"KES.variants": "Kenyan Shillings,Kenyan Shilling,KSh",
"KGS.format": "%@ sом",
// bare "Som"/"Soms" dropped — UZS (larger economy) owns it; say "Kyrgyzstani Som" for KGS
"KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,sом",
"KGS.variants": "Kyrgyzstani Soms,Kyrgyzstani Som,Soms,Som,sом",
"KHR.format": "%@ R.",
"KHR.variants": "Cambodian Riel,Riel,៛,Cambodian Riels,Riels",
"KMF.format": "%@ FC",
// bare "FC" dropped — CDF (Congolese franc, registered first) owns it; say "Comorian Franc" for KMF
"KMF.variants": "Comorian Francs,Comorian Franc",
"KMF.variants": "Comorian Francs,Comorian Franc,FC",
"KPW.format": "₩ %@",
"KPW.variants": "North Korean won",
"KRW.format": "₩ %@",
@ -223,8 +221,7 @@ export const EN: VocabLocale = {
"MVR.format": "Rf %@",
"MVR.variants": "Maldivian Rufiyaa,Rufiyaa,Rf",
"MWK.format": "%@ MK",
// bare "Kwacha" dropped — ZMW (larger economy) owns it; say "Malawian Kwacha" for MWK
"MWK.variants": "Malawian Kwacha",
"MWK.variants": "Malawian Kwacha,Kwacha",
"MXN.format": "$ %@",
"MXN.variants": "Mexican Peso,Mexican Pesos",
"MYR.format": "RM %@",
@ -304,8 +301,7 @@ export const EN: VocabLocale = {
"TJS.format": "%@ TJS",
"TJS.variants": "Tajikistani Somoni,Somoni",
"TMT.format": "%@ TMT",
// bare "Manat" dropped — AZN (registered first) owns it; say "Turkmenistan Manat" for TMT
"TMT.variants": "Turkmenistan Manat",
"TMT.variants": "Turkmenistan Manat,Manat",
"TND.format": "%@ TD",
"TND.variants": "Tunisian Dinar,Tunisian Dinars,د.ت,TD",
"TOP.format": "T$ %@",
@ -737,8 +733,7 @@ export const RU: VocabLocale = {
"MUR.variants": "Маврикийская рупия,Маврикийских рупий,Маврикийских рупиях",
"MVR.format": "%@ Rf",
"MVR.variants": "Мальдивская руфия,Руфия,Руфий,Руфиях",
// bare "Квача"/"Квачах" dropped — ZMW (larger economy) owns them; the fuller phrases stay unambiguous
"MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах",
"MWK.variants": "Малавийская квача,Малавийских квач,Малавийских квачах,Квача,Квачах",
"MXN.format": "%@ $",
"MXN.variants": "Мексиканских песо,Мексиканское песо",
"MYR.format": "%@ RM",
@ -798,8 +793,7 @@ export const RU: VocabLocale = {
"THB.variants": "Тайский бат,Бат,Батов,Батах",
"TJS.format": "%@ смн.",
"TJS.variants": "Таджикский сомони,Сомони",
// bare "Манат" dropped — AZN owns it; the fuller phrases below stay unambiguous
"TMT.variants": "Туркменский манат,Новый туркменский манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
"TMT.variants": "Туркменский манат,Новый туркменский манат,Манат,Манатов,Туркменских манатов,Маната,Туркменских маната",
"TND.variants": "Тунисские Динары,Тунисский Динар,Тунисских Динаров,Тунисских Динарах",
"TOP.format": "%@ T$",
"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 { makeApi, runExtensions } from "./extensions";
import { SumEngine } from "./engine";
@ -42,8 +41,8 @@ describe("extensions", () => {
let consoleWarnSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
@ -131,10 +130,13 @@ describe("extensions", () => {
it("has no host globals inside the sandbox", async () => {
await runExtensions(mockEngine as SumEngine, [
{ name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');" },
{ 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 () => {
@ -186,8 +188,6 @@ describe("extensions", () => {
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
}));
const { runExtensions: run } = await import("./extensions");
const { logger } = await import("./logger");
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);

View file

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

View file

@ -1,6 +1,5 @@
import { logger } from "./logger";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
import { isTauri, loadSettings, saveSettings, defaultSettingsData } from './storage';
import { invoke } from '@tauri-apps/api/core';
vi.mock('@tauri-apps/api/core', () => ({
@ -15,8 +14,7 @@ describe('storage', () => {
getItem: vi.fn(),
setItem: vi.fn(),
});
vi.spyOn(logger, 'warn').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn());
vi.stubGlobal('console', { warn: vi.fn() });
});
afterEach(() => {
@ -55,7 +53,7 @@ describe('storage', () => {
it('falls back to defaultSettingsData if localStorage throws', async () => {
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
const settings = await loadSettings();
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(settings).toEqual(defaultSettingsData);
});
@ -63,7 +61,7 @@ describe('storage', () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
const settings = await loadSettings();
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(settings).toEqual(defaultSettingsData);
});
@ -76,7 +74,7 @@ describe('storage', () => {
it('falls back to defaultSettingsData if JSON is invalid', async () => {
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
const settings = await loadSettings();
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(console.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
expect(settings).toEqual(defaultSettingsData);
});
});
@ -95,325 +93,4 @@ describe('storage', () => {
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`.
export interface DocMeta {
@ -80,7 +79,7 @@ export async function loadSettings(): Promise<SettingsData> {
: localStorage.getItem("summarum.settings");
if (raw) return { ...defaultSettingsData, ...JSON.parse(raw) };
} catch (e) {
logger.warn("loadSettings failed", e);
console.warn("loadSettings failed", e);
}
return { ...defaultSettingsData };
}
@ -100,7 +99,7 @@ export async function loadAppData(dataDir: string): Promise<AppData | null> {
: localStorage.getItem("summarum.documents");
if (raw) return JSON.parse(raw);
} catch (e) {
logger.warn("loadAppData failed", e);
console.warn("loadAppData failed", e);
}
return null;
}
@ -133,7 +132,7 @@ export async function flushAppData(): Promise<void> {
if (isTauri()) await invoke("save_documents", { dir: dirArg(currentDataDir), contents: raw });
else localStorage.setItem("summarum.documents", raw);
} 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 {
await invoke("run_backups", { dir: dirArg(dataDir), retentionDays });
} 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 {
await invoke("backup_deleted_sheet", { dir: dirArg(dataDir), title, contents });
} 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) });
}
/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */
export async function chooseFolder(): Promise<string | 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> {
@ -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) };
}
} catch (e) {
logger.warn("fetchRates failed", e);
console.warn("fetchRates failed", e);
}
return null;
}
// ---------- historical rates
export async function fetchHistoricalRatesBatch(dates: string[]): Promise<Record<string, Record<string, number>>> {
if (dates.length === 0) return {};
try {
if (isTauri()) {
return await invoke<Record<string, Record<string, number>>>("fetch_historical_rates_batch", { dates });
}
const results: Record<string, Record<string, number>> = {};
await Promise.all(
dates.map(async (date) => {
const rates = await fetchHistoricalRates(date);
if (rates) results[date] = rates;
})
);
return results;
} catch (e) {
logger.warn("fetchHistoricalRatesBatch failed", e);
return {};
}
}
export async function fetchHistoricalRates(date: string): Promise<Record<string, number> | null> {
try {
if (isTauri()) {
@ -247,7 +227,7 @@ export async function fetchHistoricalRates(date: string): Promise<Record<string,
}
return out;
} catch (e) {
logger.warn("fetchHistoricalRates failed", date, e);
console.warn("fetchHistoricalRates failed", date, e);
return null;
}
}
@ -266,7 +246,7 @@ export async function fetchMarketData(symbols: string[]): Promise<Record<string,
try {
return await invoke<Record<string, number>>("fetch_market_data", { symbols });
} catch (e) {
logger.warn("fetchMarketData failed", e);
console.warn("fetchMarketData failed", e);
return {};
}
}
@ -278,7 +258,7 @@ export async function loadExtensionScripts(): Promise<{ name: string; code: stri
try {
return await invoke<{ name: string; code: string }[]>("load_extensions");
} catch (e) {
logger.warn("loadExtensions failed", e);
console.warn("loadExtensions failed", e);
return [];
}
}
@ -315,7 +295,7 @@ export async function onFileDrop(cb: (content: string) => void): Promise<void> {
const content = await invoke<string>("read_text_file", { path });
cb(content);
} 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[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 { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
import { SumEngine, LineResult } from "../engine";
import { buildSparkline } from "./sparkline";
export interface EditorCallbacks {
onChange(text: string): void;
@ -75,8 +74,6 @@ const highlightField = StateField.define<DecorationSet>({
provide: (f) => EditorView.decorations.from(f),
});
const varHighlightReCache = new Map<string, { assignment: RegExp; highlight: RegExp }>();
/** Highlights every occurrence of the variable under the cursor. */
function buildVarHighlight(state: EditorState): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
@ -92,21 +89,11 @@ function buildVarHighlight(state: EditorState): DecorationSet {
if (a === b) return builder.finish();
const word = text.slice(a, b);
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
if (!cachedRe.assignment.test(text)) return builder.finish();
for (const m of text.matchAll(cachedRe.highlight)) {
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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" }));
}
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 {
view: EditorView;
private resultsEl: HTMLElement;
@ -170,7 +203,6 @@ export class SumEditor {
this.resultsEl = resultsEl;
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
const varPattern = /^\s*([\p{L}_][\p{L}\d_]*)\s*=/u;
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
const word = ctx.matchBefore(/[\p{L}_]+$/u);
if (!word || (word.from === word.to && !ctx.explicit)) return null;
@ -183,15 +215,9 @@ export class SumEditor {
}));
const options = [...engineOptions];
// document variables
const iter = ctx.state.doc.iterLines();
for (let next = iter.next(); !next.done; next = iter.next()) {
const line = next.value;
if (line.includes("=")) {
const m = varPattern.exec(line);
if (m) {
options.push({ label: m[1], type: "variable" });
}
}
const text = ctx.state.doc.toString();
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
options.push({ label: m[1], type: "variable" });
}
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;
}
const $ = <T extends HTMLElement>(sel: string): T =>
document.querySelector(sel) as T;
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
export function parseResultQuery(
engine: SumEngine,
q: string,
): { op: string; threshold: Value } | null {
function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null {
const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
if (!m) return null;
const v = engine.evaluateExpression(m[2].trim());
@ -44,7 +40,7 @@ export function parseResultQuery(
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();
if (!q) return [];
const docs = deps.docs();
@ -54,41 +50,22 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const hits: SearchHit[] = [];
for (const doc of docs) {
const results = deps.workspace.getCachedResults(doc);
let lines: string[] | undefined;
const lines = doc.text.split("\n");
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (!r.value || r.value.kind !== "quantity") continue;
const v = r.value.value;
let match = false;
switch (rq.op) {
case ">":
match = v.gt(th);
break;
case ">=":
match = v.gte(th);
break;
case "<":
match = v.lt(th);
break;
case "<=":
match = v.lte(th);
break;
case "=":
match = v.eq(th);
break;
case "~":
match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01);
break;
case ">": match = v.gt(th); break;
case ">=": match = v.gte(th); break;
case "<": match = v.lt(th); break;
case "<=": match = v.lte(th); break;
case "=": match = v.eq(th); break;
case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); break;
}
if (match) {
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;
}
}
@ -101,12 +78,7 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const lines = doc.text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(ql)) {
hits.push({
docId: doc.id,
docTitle: doc.title,
line: i + 1,
text: lines[i],
});
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] });
if (hits.length >= 200) return hits;
}
}
@ -141,9 +113,7 @@ export function initSearch(deps: SearchDeps): SearchController {
docEl.textContent = hit.docTitle;
const lineEl = document.createElement("div");
lineEl.className = "line";
lineEl.textContent = hit.result
? `${hit.text} = ${hit.result}`
: hit.text;
lineEl.textContent = hit.result ? `${hit.text} = ${hit.result}` : hit.text;
item.append(docEl, lineEl);
item.addEventListener("click", () => {
deps.onOpen(hit.docId, hit.line);

View file

@ -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 { checkForUpdate } from './updater';
import * as storage from './storage';
@ -23,8 +22,8 @@ vi.mock('@tauri-apps/plugin-process', () => ({
describe('checkForUpdate', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(logger, 'info').mockImplementation(() => {});
vi.spyOn(logger, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
it('should return null if not in Tauri', async () => {
@ -40,7 +39,7 @@ describe('checkForUpdate', () => {
const result = await checkForUpdate();
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 () => {
@ -53,7 +52,7 @@ describe('checkForUpdate', () => {
const result = await checkForUpdate();
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 () => {

View file

@ -1,4 +1,3 @@
import { logger } from "./logger";
import { isTauri } from "./storage";
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
// the latest published release" apart from "the check silently failed"
const { getVersion } = await import("@tauri-apps/api/app");
logger.info(`no update: running ${await getVersion()}, that's the latest published release`);
console.info(`no update: running ${await getVersion()}, that's the latest published release`);
return null;
}
logger.info(`update available: ${update.currentVersion} -> ${update.version}`);
console.info(`update available: ${update.currentVersion} -> ${update.version}`);
return {
version: update.version,
async install() {
@ -33,7 +32,7 @@ export async function checkForUpdate(): Promise<AvailableUpdate | null | "error"
},
};
} catch (e) {
logger.warn("update check failed", e);
console.warn("update check failed", e);
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", () => {
it("invalidating a sheet also invalidates its dependents, not unrelated sheets", () => {
const { ws, store } = makeWorkspace({

View file

@ -32,7 +32,8 @@ export class Workspace {
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);
try {
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[] {
return this.exportsFor(sheet).results;
}
@ -54,11 +50,10 @@ export class Workspace {
invalidate(sheetId: string): void {
this.titleCache = null;
const dirty = new Set<string>([sheetId]);
const sheetsList = this.sheets();
let changed = true;
while (changed) {
changed = false;
for (const s of sheetsList) {
for (const s of this.sheets()) {
if (dirty.has(s.id)) continue;
for (const title of this.referencedTitles(s.text, s.id)) {
const target = this.findSheetByTitle(title);
@ -150,7 +145,13 @@ export class Workspace {
private exportsFor(sheet: SheetSource): SheetExports {
const cached = this.cache.get(sheet.id);
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>();
for (const r of results) {
if (r.assign && r.value) vars.set(r.assign, r.value);