mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 22:41:58 +03:00
Compare commits
No commits in common. "main" and "v1.2.0" have entirely different histories.
63 changed files with 1127 additions and 5482 deletions
60
.github/workflows/release.yml
vendored
60
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
gh release edit "${{ github.ref_name }}" --notes-file notes.md
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,7 +2,6 @@ node_modules/
|
|||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
src-tauri/.cargo/
|
||||
*.log
|
||||
|
||||
# local reference copies of third-party sources — not ours to ship
|
||||
|
|
@ -11,10 +10,6 @@ numi-master/
|
|||
|
||||
# one-off debugging artifacts
|
||||
scripts/smoke-*
|
||||
test_perf.ts
|
||||
|
||||
# AI coding agent scratch/notes directories
|
||||
.jules/
|
||||
|
||||
# updater signing key — private, never publish (see .tauri-signing/README.txt)
|
||||
.tauri-signing/
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
when:
|
||||
- event: [push, manual]
|
||||
|
||||
steps:
|
||||
- name: test-engine
|
||||
image: node:22-bookworm-slim
|
||||
commands:
|
||||
- npm ci
|
||||
- npx tsc --noEmit
|
||||
- npx vitest run
|
||||
|
||||
- name: build-tauri-linux
|
||||
image: rust:1-bookworm
|
||||
commands:
|
||||
- rustup component add clippy
|
||||
- apt-get update && apt-get install -y libwebkit2gtk-4.1-dev librsvg2-dev libssl-dev libxdo-dev libayatana-appindicator3-dev
|
||||
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
- apt-get install -y nodejs
|
||||
- npm ci
|
||||
- cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
- cargo test --manifest-path src-tauri/Cargo.toml
|
||||
- CI=true npx tauri build --no-bundle --ci
|
||||
|
|
@ -23,16 +23,12 @@ 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
|
||||
extensions.ts runs user .js files against the engine
|
||||
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 +214,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 +241,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 +263,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 152 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
|
||||
|
|
|
|||
|
|
@ -24,16 +24,12 @@ 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.* движка
|
||||
extensions.ts запускает пользовательские .js-файлы через движок
|
||||
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 +216,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 +244,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 +267,7 @@ src-tauri/
|
|||
|
||||
## Тесты
|
||||
|
||||
`npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все
|
||||
`npm test` запускает 152 vitest-кейса: `src/engine/*.test.ts` покрывает все
|
||||
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
|
||||
неизвестного, исторические курсы (инжектированные), литералы и отображение
|
||||
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -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).
|
||||
|
||||
|
|
@ -143,7 +130,7 @@ auto-derived one) so the reference doesn't break when its first line changes.
|
|||
|
||||
## Extensions
|
||||
|
||||
> **Note:** extensions run inside a WebAssembly sandbox — no file, network or app access, only the `numi` API below. A script that hangs is cut off; a script that fails is skipped.
|
||||
> **Note:** extensions run as trusted local scripts with access to your files. Only load code you wrote or fully trust.
|
||||
|
||||
Drop a `.js` file into the extensions folder (Settings → Extensions folder)
|
||||
and restart. The API is compatible with Numi extensions:
|
||||
|
|
@ -169,7 +156,7 @@ see [ARCHITECTURE.md](ARCHITECTURE.md).
|
|||
|
||||
```bash
|
||||
npm install
|
||||
npm test # engine tests (373 cases)
|
||||
npm test # engine tests (152 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
|
||||
|
||||
|
|
|
|||
38
README.ru.md
38
README.ru.md
|
|
@ -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** или новее.
|
||||
- Интернет нужен только для живых курсов и рыночных данных — всё остальное работает офлайн
|
||||
(курсы берутся из кэша или встроенного снимка).
|
||||
|
||||
|
|
@ -139,7 +125,7 @@ AAPL в USD → $213.55
|
|||
|
||||
## Расширения
|
||||
|
||||
> **Внимание:** расширения выполняются в WebAssembly-песочнице — без доступа к файлам, сети и приложению, только API `numi` ниже. Зависший скрипт прерывается, упавший — пропускается.
|
||||
> **Внимание:** расширения запускаются как доверенные локальные скрипты с доступом к вашим файлам. Загружайте только код, который вы написали сами или полностью доверяете.
|
||||
|
||||
Брось `.js`-файл в папку расширений (Настройки → Extensions folder) и перезапусти.
|
||||
API совместим с расширениями Numi:
|
||||
|
|
@ -165,7 +151,7 @@ numi.addFunction({ id: "гипот", phrases: "гипот" },
|
|||
|
||||
```bash
|
||||
npm install
|
||||
npm test # тесты движка (373 кейса)
|
||||
npm test # тесты движка (152 кейса)
|
||||
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.
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
|
|
|||
22
index.html
22
index.html
|
|
@ -9,31 +9,31 @@
|
|||
<div id="app">
|
||||
<aside id="sidebar" class="hidden">
|
||||
<div id="doc-list"></div>
|
||||
<button id="new-doc" title="New document" aria-label="New document">+</button>
|
||||
<button id="new-doc" title="New document">+</button>
|
||||
</aside>
|
||||
<main id="content">
|
||||
<div id="titlebar" data-tauri-drag-region>
|
||||
<button id="toggle-sidebar" title="Documents (Ctrl+B)" aria-label="Documents">☰</button>
|
||||
<input id="doc-title" spellcheck="false" autocomplete="off" aria-label="Document title" />
|
||||
<button id="toggle-sidebar" title="Documents (Ctrl+B)">☰</button>
|
||||
<input id="doc-title" spellcheck="false" autocomplete="off" />
|
||||
<span class="spacer" data-tauri-drag-region></span>
|
||||
<button id="open-search" title="Search all sheets (Ctrl+F)" aria-label="Search all sheets">🔍︎</button>
|
||||
<button id="open-settings" title="Settings" aria-label="Settings">⚙</button>
|
||||
<button id="open-search" title="Search all sheets (Ctrl+F)">🔍︎</button>
|
||||
<button id="open-settings" title="Settings">⚙</button>
|
||||
<div id="win-controls">
|
||||
<button id="win-min" title="Minimize" aria-label="Minimize">─</button>
|
||||
<button id="win-close" title="Hide to tray" aria-label="Hide to tray">✕</button>
|
||||
<button id="win-min" title="Minimize">─</button>
|
||||
<button id="win-close" title="Hide to tray">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editor-wrap">
|
||||
<div id="editor"></div>
|
||||
<div id="results"></div>
|
||||
<div id="col-divider" title="Drag to resize" aria-label="Drag to resize results column"></div>
|
||||
<div id="col-divider" title="Drag to resize"></div>
|
||||
</div>
|
||||
<div 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>
|
||||
<button id="export-btn" title="Export">⤓</button>
|
||||
<div id="export-menu" class="hidden">
|
||||
<button data-action="image" data-i18n="exportImage">Copy as image</button>
|
||||
<button data-action="copy" data-i18n="exportCopy">Copy sheet</button>
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
</div>
|
||||
<div id="search-overlay" class="hidden">
|
||||
<div class="search-box">
|
||||
<input id="search-input" type="text" autocomplete="off" spellcheck="false" aria-label="Search sheets" />
|
||||
<input id="search-input" type="text" autocomplete="off" spellcheck="false" />
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -1,19 +1,18 @@
|
|||
{
|
||||
"name": "summarum",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "summarum",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/language": "^6.10.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.36.0",
|
||||
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||
|
|
@ -22,8 +21,7 @@
|
|||
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"quickjs-emscripten-core": "^0.32.0"
|
||||
"decimal.js": "^10.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
|
|
@ -534,21 +532,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jitl/quickjs-singlefile-browser-release-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-singlefile-browser-release-sync/-/quickjs-singlefile-browser-release-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-Hfdl7rh8dzxNWFRiYAYNbhn0RMF1/tO6SMH2mUW0aTibqwaAtqPRbi4WkwaIDlhNz8Z4dksJi1Zjl1R54Jsc/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -1642,9 +1625,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.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1698,9 +1681,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.24",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz",
|
||||
"integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1718,7 +1701,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -1726,15 +1709,6 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.61.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"name": "summarum",
|
||||
"private": true,
|
||||
"version": "1.2.9",
|
||||
"description": "Summarum — natural language calculator notebook",
|
||||
"version": "1.2.0",
|
||||
"description": "Summarum — natural language calculator notebook for Windows",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
"@codemirror/language": "^6.10.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.36.0",
|
||||
"@jitl/quickjs-singlefile-browser-release-sync": "^0.32.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||
|
|
@ -28,8 +27,7 @@
|
|||
"@tauri-apps/plugin-opener": "^2.2.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"quickjs-emscripten-core": "^0.32.0"
|
||||
"decimal.js": "^10.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
|
|
|
|||
38
src-tauri/Cargo.lock
generated
38
src-tauri/Cargo.lock
generated
|
|
@ -1094,21 +1094,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1116,7 +1101,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1184,7 +1168,6 @@ version = "0.3.32"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
|
|
@ -3038,7 +3021,6 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
|
@ -3058,14 +3040,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 +3085,7 @@ dependencies = [
|
|||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
|
@ -3727,10 +3707,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||
|
||||
[[package]]
|
||||
name = "summarum"
|
||||
version = "1.2.9"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -4927,19 +4906,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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "summarum"
|
||||
version = "1.2.9"
|
||||
description = "Summarum — natural language calculator notebook"
|
||||
version = "1.2.0"
|
||||
description = "Summarum — natural language calculator notebook for Windows"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
|
|
@ -18,9 +18,8 @@ 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"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// WARNING: extensions run as trusted local code with full access to your files.
|
||||
// Only load scripts you wrote yourself or fully trust.
|
||||
|
||||
// Summarum extension example.
|
||||
// Drop .js files into this folder and restart the app.
|
||||
// API: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||
//
|
||||
// Scripts run in a WebAssembly sandbox: no files, no network, no app internals,
|
||||
// and no console/setTimeout/fetch — only plain JS and the numi API above.
|
||||
|
||||
// A global variable usable on any sheet, e.g. `salary * vat`
|
||||
// numi.setVariable("vat", { "double": 20 });
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,29 +1,18 @@
|
|||
// 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_plugin_dialog::DialogExt;
|
||||
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
|
||||
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,171 +20,38 @@ 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
|
||||
}
|
||||
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());
|
||||
_ => data_dir(app),
|
||||
}
|
||||
fs::create_dir_all(&candidate).ok();
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
fn safe_name(name: &str) -> bool {
|
||||
let path = Path::new(name);
|
||||
let mut components = path.components();
|
||||
match components.next() {
|
||||
Some(std::path::Component::Normal(_)) if components.next().is_none() => {
|
||||
!name.contains(['/', '\\', ':'])
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
!name.is_empty() && !name.contains(['/', '\\', ':']) && !name.contains("..")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_text_file(app: AppHandle, contents: String, is_sum: bool) -> Result<bool, String> {
|
||||
let (name, ext) = if is_sum {
|
||||
("Summarum Sheet", "sum")
|
||||
} else {
|
||||
("Text file", "txt")
|
||||
};
|
||||
if let Some(p) = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter(name, &[ext])
|
||||
.blocking_save_file()
|
||||
{
|
||||
write_atomic(&p.into_path().map_err(|e| e.to_string())?, &contents)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
fn write_text_file(path: String, contents: String) -> Result<(), String> {
|
||||
write_atomic(std::path::Path::new(&path), &contents)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_image_file(app: AppHandle, data_base64: String) -> Result<bool, String> {
|
||||
if let Some(p) = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("PNG Image", &["png"])
|
||||
.blocking_save_file()
|
||||
{
|
||||
let path = p.into_path().map_err(|e| e.to_string())?;
|
||||
write_atomic_bytes(&path, &base64_decode(&data_base64)?)?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
fn write_image_file(path: String, data_base64: String) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
let bytes = base64_decode(&data_base64)?;
|
||||
let p = std::path::Path::new(&path);
|
||||
let tmp = p.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, p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||
|
|
@ -216,9 +72,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 +87,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 +104,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 +162,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()
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
let body: serde_json::Value = client
|
||||
.get("https://open.er-api.com/v6/latest/USD")
|
||||
.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,8 +194,7 @@ 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) {
|
||||
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 {
|
||||
|
|
@ -396,7 +204,6 @@ async fn fetch_rates_online() -> Result<RatesPayload, String> {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RatesPayload {
|
||||
date: body["time_last_update_utc"]
|
||||
|
|
@ -411,7 +218,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 +233,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 +265,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> =
|
||||
|
|
@ -489,33 +295,24 @@ async fn fetch_market_data(
|
|||
// v7/finance/quote now requires a cookie+crumb pair Yahoo stopped issuing
|
||||
// to plain API clients (returns 401); v8 chart still answers anonymously,
|
||||
// one symbol per request.
|
||||
let futures_reqs = symbols.iter().map(|sym| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let mut prices = std::collections::HashMap::new();
|
||||
for sym in &symbols {
|
||||
let url = format!(
|
||||
"https://query1.finance.yahoo.com/v8/finance/chart/{}?range=1d&interval=1d",
|
||||
sym
|
||||
);
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(price) =
|
||||
body["chart"]["result"][0]["meta"]["regularMarketPrice"].as_f64()
|
||||
{
|
||||
let Ok(resp) = client.get(&url).send().await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(body) = resp.json::<serde_json::Value>().await else {
|
||||
continue;
|
||||
};
|
||||
if let Some(price) = body["chart"]["result"][0]["meta"]["regularMarketPrice"].as_f64() {
|
||||
if price > 0.0 {
|
||||
return Some((sym.clone(), price));
|
||||
prices.insert(sym.clone(), price);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let mut prices = std::collections::HashMap::new();
|
||||
let results = futures::future::join_all(futures_reqs).await;
|
||||
for (sym, price) in results.into_iter().flatten() {
|
||||
prices.insert(sym, price);
|
||||
}
|
||||
|
||||
if !prices.is_empty() {
|
||||
let cache = MarketCache {
|
||||
|
|
@ -523,7 +320,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 +338,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 +347,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()
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
.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 +398,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 +409,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 +464,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 +481,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 +495,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 +509,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 +541,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 +564,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 +613,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 +675,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 +693,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 +702,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 +717,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 +741,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 +785,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() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Summarum",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.0",
|
||||
"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' '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": {
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"bundle": {
|
||||
"targets": ["deb", "rpm", "appimage"]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"bundle": {
|
||||
"targets": ["app", "dmg"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"bundle": {
|
||||
"targets": ["nsis"],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"languages": ["English", "Russian"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
detectDateOrder,
|
||||
resolveZone,
|
||||
startOfToday,
|
||||
addToDate,
|
||||
isCalendarUnit,
|
||||
} from "./datetime";
|
||||
|
||||
describe("datetime", () => {
|
||||
describe("detectDateOrder", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should detect dmy (e.g. en-GB)", () => {
|
||||
expect(detectDateOrder("en-GB")).toBe("dmy");
|
||||
});
|
||||
|
||||
it("should detect mdy (e.g. en-US)", () => {
|
||||
expect(detectDateOrder("en-US")).toBe("mdy");
|
||||
});
|
||||
|
||||
it("should default to dmy if neither is found", () => {
|
||||
const mockFormatToParts = vi.fn().mockReturnValue([
|
||||
{ type: "year", value: "2000" },
|
||||
]);
|
||||
|
||||
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
|
||||
formatToParts: mockFormatToParts,
|
||||
}) as any);
|
||||
|
||||
expect(detectDateOrder()).toBe("dmy");
|
||||
mockDateTimeFormat.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveZone", () => {
|
||||
it("should resolve 'local' to system timezone", () => {
|
||||
const mockResolvedOptions = vi.fn().mockReturnValue({ timeZone: "America/Los_Angeles" });
|
||||
const mockDateTimeFormat = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(() => ({
|
||||
resolvedOptions: mockResolvedOptions,
|
||||
}) as any);
|
||||
|
||||
expect(resolveZone(["local"])).toBe("America/Los_Angeles");
|
||||
mockDateTimeFormat.mockRestore();
|
||||
});
|
||||
|
||||
it("should resolve known abbreviations", () => {
|
||||
expect(resolveZone(["utc"])).toBe("UTC");
|
||||
expect(resolveZone(["est"])).toBe("America/New_York");
|
||||
expect(resolveZone(["pst"])).toBe("America/Los_Angeles");
|
||||
expect(resolveZone(["jst"])).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("should resolve known cities", () => {
|
||||
expect(resolveZone(["new", "york"])).toBe("America/New_York");
|
||||
expect(resolveZone(["london"])).toBe("Europe/London");
|
||||
expect(resolveZone(["tokyo"])).toBe("Asia/Tokyo");
|
||||
expect(resolveZone(["buenos", "aires"])).toBe("America/Argentina/Buenos_Aires");
|
||||
});
|
||||
|
||||
it("should resolve russian cities", () => {
|
||||
expect(resolveZone(["нью", "йорк"])).toBe("America/New_York");
|
||||
expect(resolveZone(["москва"])).toBe("Europe/Moscow");
|
||||
});
|
||||
|
||||
it("should ignore punctuation and case", () => {
|
||||
expect(resolveZone(["New-York"])).toBe("America/New_York");
|
||||
expect(resolveZone(["LONdoN."])).toBe("Europe/London");
|
||||
});
|
||||
|
||||
it("should resolve raw IANA timezone", () => {
|
||||
expect(resolveZone(["Europe", "Berlin"])).toBe("Europe/Berlin");
|
||||
expect(resolveZone(["America", "Argentina", "Mendoza"])).toBe("America/Argentina/Mendoza");
|
||||
});
|
||||
|
||||
it("should return null for unknown timezones", () => {
|
||||
expect(resolveZone(["Unknown", "City"])).toBeNull();
|
||||
expect(resolveZone(["Invalid/Timezone"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("startOfToday", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return midnight of the current day", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-05-15T14:30:00Z"));
|
||||
|
||||
const expected = new Date("2024-05-15T14:30:00Z");
|
||||
expected.setHours(0, 0, 0, 0);
|
||||
|
||||
expect(startOfToday()).toBe(expected.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("addToDate", () => {
|
||||
it("should add days", () => {
|
||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 5, "day");
|
||||
expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should add weeks", () => {
|
||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 2, "week");
|
||||
expect(new Date(res).toISOString()).toBe("2024-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should add months", () => {
|
||||
const ms = new Date("2024-01-15T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 2, "month");
|
||||
expect(new Date(res).toISOString()).toBe("2024-03-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should clamp end of month correctly", () => {
|
||||
const ms = new Date("2024-01-31T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 1, "month");
|
||||
// 2024 is a leap year, so Feb 29
|
||||
expect(new Date(res).toISOString()).toBe("2024-02-29T12:00:00.000Z");
|
||||
|
||||
const ms2 = new Date("2023-01-31T12:00:00Z").getTime();
|
||||
const res2 = addToDate(ms2, 1, "month");
|
||||
// 2023 is not a leap year, so Feb 28
|
||||
expect(new Date(res2).toISOString()).toBe("2023-02-28T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should add years", () => {
|
||||
const ms = new Date("2024-01-15T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 3, "year");
|
||||
expect(new Date(res).toISOString()).toBe("2027-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should clamp end of month when adding years to leap day", () => {
|
||||
const ms = new Date("2024-02-29T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 1, "year");
|
||||
expect(new Date(res).toISOString()).toBe("2025-02-28T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should handle unit ids with prefixes", () => {
|
||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 5, "unit:day");
|
||||
expect(new Date(res).toISOString()).toBe("2024-01-06T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should return original ms if amount is not an integer", () => {
|
||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 1.5, "day");
|
||||
expect(res).toBe(ms);
|
||||
});
|
||||
|
||||
it("should return original ms for unhandled units", () => {
|
||||
const ms = new Date("2024-01-01T12:00:00Z").getTime();
|
||||
const res = addToDate(ms, 1, "hour");
|
||||
expect(res).toBe(ms);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCalendarUnit", () => {
|
||||
it("should return true for valid calendar units with integer amounts", () => {
|
||||
expect(isCalendarUnit("day", 1)).toBe(true);
|
||||
expect(isCalendarUnit("week", 5)).toBe(true);
|
||||
expect(isCalendarUnit("month", -2)).toBe(true);
|
||||
expect(isCalendarUnit("year", 10)).toBe(true);
|
||||
expect(isCalendarUnit("unit:day", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for floating point amounts", () => {
|
||||
expect(isCalendarUnit("day", 1.5)).toBe(false);
|
||||
expect(isCalendarUnit("month", 0.5)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-calendar units", () => {
|
||||
expect(isCalendarUnit("hour", 1)).toBe(false);
|
||||
expect(isCalendarUnit("minute", 1)).toBe(false);
|
||||
expect(isCalendarUnit("unit:meter", 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle edge cases for amount", () => {
|
||||
expect(isCalendarUnit("day", 0)).toBe(true);
|
||||
expect(isCalendarUnit("day", Number.MAX_SAFE_INTEGER)).toBe(true);
|
||||
expect(isCalendarUnit("day", Number.MIN_SAFE_INTEGER)).toBe(true);
|
||||
|
||||
expect(isCalendarUnit("day", NaN)).toBe(false);
|
||||
expect(isCalendarUnit("day", Infinity)).toBe(false);
|
||||
expect(isCalendarUnit("day", -Infinity)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle edge cases for unitId string format", () => {
|
||||
expect(isCalendarUnit("", 1)).toBe(false);
|
||||
expect(isCalendarUnit(":", 1)).toBe(false);
|
||||
|
||||
// string.split(":") behavior:
|
||||
expect(isCalendarUnit("time:month", 1)).toBe(true);
|
||||
expect(isCalendarUnit(":day", 1)).toBe(true);
|
||||
expect(isCalendarUnit("day:", 1)).toBe(false);
|
||||
expect(isCalendarUnit("a:day:c", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for case-sensitive mismatch", () => {
|
||||
expect(isCalendarUnit("Day", 1)).toBe(false);
|
||||
expect(isCalendarUnit("YEAR", 1)).toBe(false);
|
||||
expect(isCalendarUnit("MONTH", 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -216,12 +215,6 @@ 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");
|
||||
});
|
||||
expect(calc("throw_err(1) 2 hours")).toBe("2 h");
|
||||
});
|
||||
it("assignment and reuse", () => {
|
||||
expect(calcDoc("x = 5\nx * 2")).toEqual(["5", "10"]);
|
||||
});
|
||||
|
|
@ -335,13 +328,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 +417,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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,88 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { formatNumber, formatValue } from "./formatter";
|
||||
import { Decimal, defaultSettings, qty, pct, DateVal, ChartValue, EngineSettings } from "./types";
|
||||
|
||||
describe("formatNumber", () => {
|
||||
it("formats standard numbers", () => {
|
||||
expect(formatNumber(new Decimal("12.345"), defaultSettings)).toBe("12.35");
|
||||
expect(formatNumber(new Decimal("100"), defaultSettings)).toBe("100");
|
||||
});
|
||||
|
||||
it("handles custom group separators", () => {
|
||||
const s: EngineSettings = { ...defaultSettings, groupSeparator: " " };
|
||||
expect(formatNumber(new Decimal("1234567.89"), s)).toBe("1 234 567.89");
|
||||
});
|
||||
|
||||
it("handles custom decimal separators", () => {
|
||||
const s: EngineSettings = { ...defaultSettings, decimalSeparator: "," };
|
||||
expect(formatNumber(new Decimal("12.345"), s)).toBe("12,35");
|
||||
});
|
||||
|
||||
it("handles scientific notation strings automatically", () => {
|
||||
expect(formatNumber(new Decimal("1e25"), defaultSettings)).toBe("1e+25");
|
||||
});
|
||||
|
||||
it("applies significant digits fallback for numbers close to zero", () => {
|
||||
// 0.001 rounded to 0 decimals is 0, but since >= 1e-12, it shows significant digits
|
||||
const s: EngineSettings = { ...defaultSettings, precision: 0 };
|
||||
expect(formatNumber(new Decimal("0.001"), s)).toBe("0.001");
|
||||
});
|
||||
|
||||
it("treats numbers < 1e-12 as conversion noise", () => {
|
||||
const s: EngineSettings = { ...defaultSettings, precision: 2 };
|
||||
expect(formatNumber(new Decimal("1e-13"), s)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatValue", () => {
|
||||
it("formats chart as empty string", () => {
|
||||
const v: ChartValue = { kind: "chart", points: [], unitLabel: null };
|
||||
expect(formatValue(v, defaultSettings)).toBe("");
|
||||
});
|
||||
|
||||
it("formats percent values", () => {
|
||||
expect(formatValue(pct(25.5), defaultSettings)).toBe("25.5%");
|
||||
});
|
||||
|
||||
it("formats dates depending on flags", () => {
|
||||
// 2024-01-01 12:00:00 UTC = 1704110400000
|
||||
const d: DateVal = { kind: "date", ms: 1704110400000, hasTime: false, timeZone: "UTC" };
|
||||
expect(formatValue(d, { ...defaultSettings, dateFormat: "iso" })).toBe("2024-01-01");
|
||||
expect(formatValue(d, { ...defaultSettings, dateFormat: "dmy" })).toBe("01.01.2024");
|
||||
expect(formatValue(d, { ...defaultSettings, dateFormat: "mdy" })).toBe("01/01/2024");
|
||||
|
||||
// Test timeOnly
|
||||
const dTime: DateVal = { kind: "date", ms: 1704110400000, hasTime: true, timeZone: "UTC", timeOnly: true };
|
||||
expect(formatValue(dTime, defaultSettings)).toBe("12:00");
|
||||
});
|
||||
|
||||
it("formats base conversions", () => {
|
||||
expect(formatValue(qty(255, null, "hex"), defaultSettings)).toBe("0xFF");
|
||||
expect(formatValue(qty(-255, null, "hex"), defaultSettings)).toBe("-0xFF");
|
||||
expect(formatValue(qty(5, null, "binary"), defaultSettings)).toBe("0b101");
|
||||
expect(formatValue(qty(8, null, "octal"), defaultSettings)).toBe("0o10");
|
||||
});
|
||||
|
||||
it("formats representations", () => {
|
||||
expect(formatValue(qty(123456, null, "scientific"), defaultSettings)).toBe("1.23e5");
|
||||
expect(formatValue(qty(1234.56, null, "plain"), defaultSettings)).toBe("1234.56");
|
||||
expect(formatValue(qty(0.75, null, "fraction"), defaultSettings)).toBe("3/4");
|
||||
expect(formatValue(qty(14, null, "roman"), defaultSettings)).toBe("XIV");
|
||||
});
|
||||
|
||||
it("formats Roman numerals fallbacks", () => {
|
||||
// Beyond 3999 falls back to formatNumber
|
||||
expect(formatValue(qty(4000, null, "roman"), defaultSettings)).toBe("4,000");
|
||||
});
|
||||
|
||||
it("formats units", () => {
|
||||
const cm = { id: "cm", dimension: "length" as const, ratio: new Decimal(0.01), format: "cm" };
|
||||
expect(formatValue(qty(10, cm), defaultSettings)).toBe("10 cm");
|
||||
|
||||
const degC = { id: "degC", dimension: "temperature" as const, ratio: new Decimal(1), format: "°C" };
|
||||
expect(formatValue(qty(25, degC), defaultSettings)).toBe("25°C");
|
||||
|
||||
const usd = { id: "USD", dimension: "currency" as const, ratio: new Decimal(1), format: "${}" };
|
||||
expect(formatValue(qty(100, usd), defaultSettings)).toBe("$100");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,17 +118,12 @@ 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,
|
||||
|
|
@ -138,23 +132,18 @@ export class SumEngine {
|
|||
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) {
|
||||
// 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.
|
||||
if (e instanceof XRefError) lineError = e.message;
|
||||
else if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
|
||||
value = null;
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { lexLine } from "./lexer";
|
||||
import { Decimal } from "./types";
|
||||
|
||||
describe("lexer", () => {
|
||||
describe("lexLine", () => {
|
||||
it("should lex words and symbols, ignoring spaces", () => {
|
||||
const result = lexLine("hello + world");
|
||||
expect(result).toEqual([
|
||||
{ type: "word", raw: "hello", start: 0, end: 5 },
|
||||
{ type: "sym", raw: "+", start: 6, end: 7 },
|
||||
{ type: "word", raw: "world", start: 8, end: 13 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex ISO dates", () => {
|
||||
const result = lexLine("2024-12-31");
|
||||
expect(result).toEqual([
|
||||
{ type: "date", raw: "2024-12-31", start: 0, end: 10, dateMs: new Date(2024, 11, 31).getTime() },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex dmy dates", () => {
|
||||
const result = lexLine("31.12.2024");
|
||||
expect(result).toEqual([
|
||||
{ type: "date", raw: "31.12.2024", start: 0, end: 10, dateMs: new Date(2024, 11, 31).getTime() },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex ambiguous dates as dmy by default", () => {
|
||||
const result = lexLine("01/02/2024");
|
||||
expect(result).toEqual([
|
||||
{ type: "date", raw: "01/02/2024", start: 0, end: 10, dateMs: new Date(2024, 1, 1).getTime() },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex ambiguous dates as mdy if specified", () => {
|
||||
const result = lexLine("01/02/2024", "mdy");
|
||||
expect(result).toEqual([
|
||||
{ type: "date", raw: "01/02/2024", start: 0, end: 10, dateMs: new Date(2024, 0, 2).getTime() },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fallback when invalid dates are encountered", () => {
|
||||
// 2026-13-45 is invalid as a date, should lex as numbers and syms
|
||||
const result = lexLine("2026-13-45");
|
||||
expect(result[0].type).toBe("num");
|
||||
expect(result[0].raw).toBe("2026");
|
||||
expect(result[1].type).toBe("sym");
|
||||
expect(result[1].raw).toBe("-");
|
||||
});
|
||||
|
||||
it("should lex plain integers", () => {
|
||||
const result = lexLine("42");
|
||||
expect(result).toEqual([
|
||||
{ type: "num", raw: "42", start: 0, end: 2, value: new Decimal("42"), repr: "decimal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex decimal comma numbers", () => {
|
||||
const result = lexLine("42,5");
|
||||
expect(result).toEqual([
|
||||
{ type: "num", raw: "42,5", start: 0, end: 4, value: new Decimal("42.5"), repr: "decimal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex space-grouped numbers", () => {
|
||||
const result = lexLine("1 000 000,5");
|
||||
expect(result).toEqual([
|
||||
{ type: "num", raw: "1 000 000,5", start: 0, end: 11, value: new Decimal("1000000.5"), repr: "decimal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex comma-grouped numbers", () => {
|
||||
const result = lexLine("1,000,000.5");
|
||||
expect(result).toEqual([
|
||||
{ type: "num", raw: "1,000,000.5", start: 0, end: 11, value: new Decimal("1000000.5"), repr: "decimal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex numbers with leading dots", () => {
|
||||
const result = lexLine(".5");
|
||||
expect(result).toEqual([
|
||||
{ type: "num", raw: ".5", start: 0, end: 2, value: new Decimal(".5"), repr: "decimal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex base numbers (hex, bin, oct)", () => {
|
||||
const hex = lexLine("0xff");
|
||||
expect(hex[0]).toMatchObject({ type: "num", raw: "0xff", value: new Decimal(255), repr: "hex" });
|
||||
|
||||
const bin = lexLine("0b1010");
|
||||
expect(bin[0]).toMatchObject({ type: "num", raw: "0b1010", value: new Decimal(10), repr: "binary" });
|
||||
|
||||
const oct = lexLine("0o77");
|
||||
expect(oct[0]).toMatchObject({ type: "num", raw: "0o77", value: new Decimal(63), repr: "octal" });
|
||||
});
|
||||
|
||||
it("should lex bare cross-references", () => {
|
||||
const result = lexLine("@sheet.key");
|
||||
expect(result).toEqual([
|
||||
{ type: "xref", raw: "@sheet.key", start: 0, end: 10, sheet: "sheet", key: "key" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should lex bracketed cross-references", () => {
|
||||
const result = lexLine("@[my sheet].key");
|
||||
expect(result).toEqual([
|
||||
{ type: "xref", raw: "@[my sheet].key", start: 0, end: 15, sheet: "my sheet", key: "key" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -34,12 +34,25 @@ function validDateMs(y: number, mo: number, d: number): number | null {
|
|||
: null;
|
||||
}
|
||||
|
||||
function tryLexDate(rest: string, i: number, dateOrder: DateOrder): Lex | null {
|
||||
export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
|
||||
const out: Lex[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === " " || ch === "\t" || ch === " ") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const rest = text.slice(i);
|
||||
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
const iso = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(rest);
|
||||
if (iso) {
|
||||
const ms = validDateMs(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
||||
if (ms !== null) {
|
||||
return { type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms };
|
||||
out.push({ type: "date", raw: iso[0], start: i, end: i + iso[0].length, dateMs: ms });
|
||||
i += iso[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const dm = SLASH_DOT_DATE_RE.exec(rest);
|
||||
|
|
@ -51,29 +64,33 @@ function tryLexDate(rest: string, i: number, dateOrder: DateOrder): Lex | null {
|
|||
const y = Number(dm[4]);
|
||||
const ms = dayFirst ? validDateMs(y, b, a) : validDateMs(y, a, b);
|
||||
if (ms !== null) {
|
||||
return { type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms };
|
||||
out.push({ type: "date", raw: dm[0], start: i, end: i + dm[0].length, dateMs: ms });
|
||||
i += dm[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryLexBaseNumber(rest: string, i: number): Lex | null {
|
||||
}
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
const hex = /^0[xX][0-9a-fA-F]+/.exec(rest);
|
||||
if (hex) {
|
||||
return { type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" };
|
||||
out.push({ type: "num", raw: hex[0], start: i, end: i + hex[0].length, value: new Decimal(parseInt(hex[0], 16)), repr: "hex" });
|
||||
i += hex[0].length;
|
||||
continue;
|
||||
}
|
||||
const bin = /^0[bB][01]+/.exec(rest);
|
||||
if (bin) {
|
||||
return { type: "num", raw: bin[0], start: i, end: i + bin[0].length, value: new Decimal(parseInt(bin[0].slice(2), 2)), repr: "binary" };
|
||||
out.push({ type: "num", raw: bin[0], start: i, end: i + bin[0].length, value: new Decimal(parseInt(bin[0].slice(2), 2)), repr: "binary" });
|
||||
i += bin[0].length;
|
||||
continue;
|
||||
}
|
||||
const oct = /^0[oO][0-7]+/.exec(rest);
|
||||
if (oct) {
|
||||
return { type: "num", raw: oct[0], start: i, end: i + oct[0].length, value: new Decimal(parseInt(oct[0].slice(2), 8)), repr: "octal" };
|
||||
out.push({ type: "num", raw: oct[0], start: i, end: i + oct[0].length, value: new Decimal(parseInt(oct[0].slice(2), 8)), repr: "octal" });
|
||||
i += oct[0].length;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryLexDecimalNumber(rest: string, i: number): Lex | null {
|
||||
}
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
let raw: string | null = null;
|
||||
let cleaned = "";
|
||||
const sp = NUM_SPACE_GROUPED_RE.exec(rest);
|
||||
|
|
@ -94,65 +111,37 @@ function tryLexDecimalNumber(rest: string, i: number): Lex | null {
|
|||
cleaned = raw;
|
||||
}
|
||||
if (raw) {
|
||||
return { type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), repr: "decimal" };
|
||||
out.push({ type: "num", raw, start: i, end: i + raw.length, value: new Decimal(cleaned), repr: "decimal" });
|
||||
i += raw.length;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryLexDotNumber(rest: string, i: number): Lex | null {
|
||||
if (/^\.\d/.test(rest)) {
|
||||
}
|
||||
if (ch === "." && /^\.\d/.test(rest)) {
|
||||
const m = /^\.\d+/.exec(rest)!;
|
||||
return { type: "num", raw: m[0], start: i, end: i + m[0].length, value: new Decimal(m[0]), repr: "decimal" };
|
||||
out.push({ type: "num", raw: m[0], start: i, end: i + m[0].length, value: new Decimal(m[0]), repr: "decimal" });
|
||||
i += m[0].length;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryLexXRef(rest: string, i: number): Lex | null {
|
||||
if (ch === "@") {
|
||||
const bracket = /^@\[([^\]]+)\]\.([\p{L}_][\p{L}\d_]*)/u.exec(rest);
|
||||
if (bracket) {
|
||||
return { type: "xref", raw: bracket[0], start: i, end: i + bracket[0].length, sheet: bracket[1].trim(), key: bracket[2] };
|
||||
out.push({ type: "xref", raw: bracket[0], start: i, end: i + bracket[0].length, sheet: bracket[1].trim(), key: bracket[2] });
|
||||
i += bracket[0].length;
|
||||
continue;
|
||||
}
|
||||
const bare = /^@([\p{L}_][\p{L}\d_]*)\.([\p{L}_][\p{L}\d_]*)/u.exec(rest);
|
||||
if (bare) {
|
||||
return { type: "xref", raw: bare[0], start: i, end: i + bare[0].length, sheet: bare[1], key: bare[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryLexWord(rest: string, i: number): Lex | null {
|
||||
const word = WORD_RE.exec(rest);
|
||||
if (word) {
|
||||
return { type: "word", raw: word[0], start: i, end: i + word[0].length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function lexLine(text: string, dateOrder: DateOrder = "dmy"): Lex[] {
|
||||
const out: Lex[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === " " || ch === "\t" || ch === " ") {
|
||||
i++;
|
||||
out.push({ type: "xref", raw: bare[0], start: i, end: i + bare[0].length, sheet: bare[1], key: bare[2] });
|
||||
i += bare[0].length;
|
||||
continue;
|
||||
}
|
||||
const rest = text.slice(i);
|
||||
|
||||
let lex: Lex | null = null;
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
lex = tryLexDate(rest, i, dateOrder) || tryLexBaseNumber(rest, i) || tryLexDecimalNumber(rest, i);
|
||||
} else if (ch === ".") {
|
||||
lex = tryLexDotNumber(rest, i);
|
||||
} else if (ch === "@") {
|
||||
lex = tryLexXRef(rest, i);
|
||||
}
|
||||
if (!lex) {
|
||||
lex = tryLexWord(rest, i);
|
||||
}
|
||||
|
||||
if (lex) {
|
||||
out.push(lex);
|
||||
i += lex.raw.length;
|
||||
const word = WORD_RE.exec(rest);
|
||||
if (word) {
|
||||
out.push({ type: "word", raw: word[0], start: i, end: i + word[0].length });
|
||||
i += word[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseLine } from "./parser";
|
||||
import { tokenize } from "./tokenizer";
|
||||
import { buildRegistry } from "./registry";
|
||||
import { Decimal } from "./types";
|
||||
|
||||
const reg = buildRegistry();
|
||||
|
||||
function parse(line: string, knownVars = new Set<string>()) {
|
||||
const tokens = tokenize(line, reg);
|
||||
return parseLine(tokens, knownVars, line);
|
||||
}
|
||||
|
||||
describe("parseLine", () => {
|
||||
it("parses basic expressions", () => {
|
||||
const res = parse("2 + 2");
|
||||
expect(res.assign).toBeUndefined();
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "bin",
|
||||
op: "plus",
|
||||
l: { k: "num", v: new Decimal(2) },
|
||||
r: { k: "num", v: new Decimal(2) }
|
||||
});
|
||||
});
|
||||
|
||||
it("parses variable assignment with '='", () => {
|
||||
const res = parse("x = 5 + 3");
|
||||
expect(res.assign).toBe("x");
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "bin",
|
||||
op: "plus",
|
||||
l: { k: "num", v: new Decimal(5) },
|
||||
r: { k: "num", v: new Decimal(3) }
|
||||
});
|
||||
});
|
||||
|
||||
it("parses variable assignment with 'is'", () => {
|
||||
const res = parse("myVar is 10");
|
||||
expect(res.assign).toBe("myVar");
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "num",
|
||||
v: new Decimal(10)
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid variable names for assignment", () => {
|
||||
const res = parse("123 = 5");
|
||||
expect(res.assign).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles known variables shadowing built-ins", () => {
|
||||
// "m" is normally a unit (meters), but if it's a known variable, it's treated as a word
|
||||
const res = parse("m = 5", new Set(["m"]));
|
||||
expect(res.assign).toBe("m");
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "num",
|
||||
v: new Decimal(5)
|
||||
});
|
||||
});
|
||||
|
||||
it("filters noise words", () => {
|
||||
// "what is 2 + 2" -> "what" and "is" are noise, leaving "2 + 2"
|
||||
const res = parse("what is 2 + 2");
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "bin",
|
||||
op: "plus"
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps words after a conversion operator (e.g., timezones)", () => {
|
||||
// "now in Europe/Berlin"
|
||||
// "Europe/Berlin" should be kept as a timezone, not dropped as noise.
|
||||
const res = parse("now in Europe/Berlin");
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "conv",
|
||||
target: {
|
||||
type: "tz"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("parses goal seek expressions", () => {
|
||||
const res = parse("? * 1.2 = 1000");
|
||||
expect(res.expr).toMatchObject({
|
||||
k: "goalseek",
|
||||
lhs: { k: "bin", op: "mul", l: { k: "unknown" }, r: { k: "num", v: new Decimal(1.2) } },
|
||||
rhs: { k: "num", v: new Decimal(1000) }
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null expression for empty or noise-only lines", () => {
|
||||
const res = parse("just some noise");
|
||||
expect(res.expr).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
const r = this.parseAdd();
|
||||
if (!r) {
|
||||
this.i = save;
|
||||
break;
|
||||
}
|
||||
l = { k: "bit", op: tk.op, l, r };
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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 loop; }
|
||||
lhs = { k: "conv", x: lhs, target: this.tryHistoricalDate(target) };
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
this.i = save;
|
||||
break;
|
||||
}
|
||||
return lhs;
|
||||
l = { k: "conv", x: l, target: this.tryHistoricalDate(target) };
|
||||
} else if (tk?.t === "pctop" && (tk.op.startsWith("as_pct") || tk.op.endsWith("what_is"))) {
|
||||
this.i++;
|
||||
const r = this.parseAdd();
|
||||
if (!r) break;
|
||||
l = { k: "pctop", op: tk.op, l, r };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
/** If the current token is `pctop(on)` + `datelit`, consume them and attach `onDate`. */
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { buildRegistry, Registry } from "./registry";
|
||||
import { lexLine } from "./lexer";
|
||||
import { Decimal } from "./types";
|
||||
|
||||
describe("registry", () => {
|
||||
let reg: Registry;
|
||||
|
||||
beforeEach(() => {
|
||||
reg = buildRegistry();
|
||||
});
|
||||
|
||||
describe("buildRegistry", () => {
|
||||
it("initializes a valid Registry instance with core vocabulary", () => {
|
||||
// Check core words
|
||||
const plusPayload = reg.match(lexLine("plus"), ["plus"], 0);
|
||||
expect(plusPayload).not.toBeNull();
|
||||
expect(plusPayload?.payload.t).toBe("op");
|
||||
if (plusPayload?.payload.t === "op") expect(plusPayload.payload.op).toBe("plus");
|
||||
|
||||
const piPayload = reg.match(lexLine("pi"), ["pi"], 0);
|
||||
expect(piPayload).not.toBeNull();
|
||||
expect(piPayload?.payload.t).toBe("const");
|
||||
if (piPayload?.payload.t === "const") expect(piPayload.payload.name).toBe("pi");
|
||||
});
|
||||
|
||||
it("registers built-in functions", () => {
|
||||
expect(reg.isFunc("sin")).toBe(true);
|
||||
expect(reg.isFunc("sqrt")).toBe(true);
|
||||
expect(reg.isFunc("random")).toBe(true);
|
||||
expect(reg.isFunc("unknownFunc")).toBe(false);
|
||||
|
||||
const sinPayload = reg.match(lexLine("sin"), ["sin"], 0);
|
||||
expect(sinPayload).not.toBeNull();
|
||||
expect(sinPayload?.payload.t).toBe("func");
|
||||
});
|
||||
|
||||
it("registers common units", () => {
|
||||
// "meter"
|
||||
const meterPayload = reg.match(lexLine("meter"), ["meter"], 0);
|
||||
expect(meterPayload).not.toBeNull();
|
||||
expect(meterPayload?.payload.t).toBe("unit");
|
||||
|
||||
// "kg" (strict/lenient)
|
||||
const kgPayload = reg.match(lexLine("kg"), ["kg"], 0);
|
||||
expect(kgPayload).not.toBeNull();
|
||||
expect(kgPayload?.payload.t).toBe("unit");
|
||||
|
||||
// "USD" (strict/lenient currency)
|
||||
const usdPayload = reg.match(lexLine("USD"), ["usd"], 0);
|
||||
expect(usdPayload).not.toBeNull();
|
||||
expect(usdPayload?.payload.t).toBe("currency");
|
||||
});
|
||||
});
|
||||
|
||||
describe("match", () => {
|
||||
it("matches single-word phrases", () => {
|
||||
const match = reg.match(lexLine("usd"), ["usd"], 0);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("matches multi-word phrases", () => {
|
||||
const match = reg.match(lexLine("square meter"), ["square", "meter"], 0);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.length).toBe(2);
|
||||
expect(match?.payload.t).toBe("unit");
|
||||
});
|
||||
|
||||
it("respects case-sensitivity", () => {
|
||||
// "m" (milli) vs "M" (mega)
|
||||
const mMatch = reg.match(lexLine("mm"), ["mm"], 0);
|
||||
const mPayload = mMatch?.payload;
|
||||
expect(mPayload?.t).toBe("unit");
|
||||
|
||||
const MMatch = reg.match(lexLine("Mm"), ["mm"], 0);
|
||||
const MPayload = MMatch?.payload;
|
||||
expect(MPayload?.t).toBe("unit");
|
||||
|
||||
if (mPayload?.t === "unit" && MPayload?.t === "unit") {
|
||||
expect(mPayload.unit.id).not.toBe(MPayload.unit.id);
|
||||
expect(mPayload.unit.id).toBe("milli:meter");
|
||||
expect(MPayload.unit.id).toBe("mega:meter");
|
||||
}
|
||||
});
|
||||
|
||||
it("respects mustTouch flag", () => {
|
||||
// "log2" vs "log 2"
|
||||
const lex2 = lexLine("log2");
|
||||
const log2Match = reg.match(lex2, lex2.map(l => l.raw), 0);
|
||||
expect(log2Match).not.toBeNull();
|
||||
expect(log2Match?.payload.t).toBe("func");
|
||||
if (log2Match?.payload.t === "func") expect(log2Match.payload.name).toBe("log2");
|
||||
|
||||
const lexSpace2 = lexLine("log 2");
|
||||
const logSpace2Match = reg.match(lexSpace2, lexSpace2.map(l => l.raw), 0);
|
||||
// "log 2" should match "log", length 1, not "log2"
|
||||
expect(logSpace2Match).not.toBeNull();
|
||||
expect(logSpace2Match?.length).toBe(1);
|
||||
if (logSpace2Match?.payload.t === "func") expect(logSpace2Match.payload.name).toBe("log");
|
||||
});
|
||||
|
||||
it("returns null for no match", () => {
|
||||
const match = reg.match(lexLine("unknownword"), ["unknownword"], 0);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("currency and rates", () => {
|
||||
it("sets and updates conversion rates", () => {
|
||||
reg.setRates({ "EUR": 0.9, "GBP": "0.8" });
|
||||
expect(reg.rates.get("EUR")?.toNumber()).toBe(0.9);
|
||||
expect(reg.rates.get("GBP")?.toNumber()).toBe(0.8);
|
||||
// USD is implicitly set to 1
|
||||
expect(reg.rates.get("USD")?.toNumber()).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores negative rates", () => {
|
||||
reg.setRates({ "EUR": 0.9, "INVALID": -1 });
|
||||
expect(reg.rates.get("EUR")?.toNumber()).toBe(0.9);
|
||||
expect(reg.rates.has("INVALID")).toBe(false);
|
||||
});
|
||||
|
||||
it("creates currency unit from rate", () => {
|
||||
const unit = reg.makeCurrencyUnitFromRate("EUR", new Decimal(0.9));
|
||||
expect(unit.id).toBe("EUR");
|
||||
expect(unit.dimension).toBe("currency");
|
||||
// 1 / 0.9 = 1.111...
|
||||
expect(unit.ratio.toNumber()).toBeCloseTo(1.1111, 4);
|
||||
});
|
||||
|
||||
it("makes currency unit if rate exists", () => {
|
||||
reg.setRates({ "EUR": 0.9 });
|
||||
const unit = reg.makeCurrencyUnit("EUR");
|
||||
expect(unit).not.toBeNull();
|
||||
expect(unit?.id).toBe("EUR");
|
||||
|
||||
const noUnit = reg.makeCurrencyUnit("JPY");
|
||||
expect(noUnit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completions", () => {
|
||||
it("populates completions with expected categories", () => {
|
||||
expect(reg.completions.length).toBeGreaterThan(0);
|
||||
|
||||
const types = new Set(reg.completions.map(c => c.type));
|
||||
expect(types.has("unit")).toBe(true);
|
||||
expect(types.has("currency")).toBe(true);
|
||||
expect(types.has("function")).toBe(true);
|
||||
expect(types.has("keyword")).toBe(true);
|
||||
});
|
||||
|
||||
it("contains specific known completions", () => {
|
||||
const labels = reg.completions.map(c => c.label);
|
||||
expect(labels).toContain("USD");
|
||||
expect(labels).toContain("meter");
|
||||
expect(labels).toContain("sin");
|
||||
expect(labels).toContain("sum");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@ import * as vocab from "./vocab";
|
|||
import { UNIT_DATA, SI_PREFIXES, DATA_SI_PREFIXES, IEC_PREFIXES, SCALE_DATA } from "./unitdata";
|
||||
import { 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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { tokenize } from "./tokenizer";
|
||||
import { buildRegistry, Registry } from "./registry";
|
||||
|
||||
describe("tokenizer", () => {
|
||||
let reg: Registry;
|
||||
|
||||
beforeAll(() => {
|
||||
reg = buildRegistry();
|
||||
});
|
||||
|
||||
it("tokenizes numbers and basic operators", () => {
|
||||
const tokens = tokenize("1 + 2 * 3.5 - 4 / 2 ^ 3", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual([
|
||||
"num", "op", "num", "op", "num", "op", "num", "op", "num", "op", "num"
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles dates", () => {
|
||||
const tokens = tokenize("2024-01-01 + 5 days", reg);
|
||||
expect(tokens[0].t).toBe("datelit");
|
||||
expect(tokens.map(t => t.t)).toEqual(["datelit", "op", "num", "unit"]);
|
||||
});
|
||||
|
||||
it("handles xrefs", () => {
|
||||
const tokens = tokenize("@Sheet.key", reg);
|
||||
expect(tokens[0].t).toBe("xref");
|
||||
if (tokens[0].t === "xref") {
|
||||
expect(tokens[0].sheet).toBe("Sheet");
|
||||
expect(tokens[0].key).toBe("key");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles bitwise operators", () => {
|
||||
const tokens = tokenize("5 << 1 >> 2 & 3 | 4", reg);
|
||||
const typesAndOps = tokens.map(t => t.t === "bitop" ? `bitop:${t.op}` : t.t);
|
||||
expect(typesAndOps).toEqual([
|
||||
"num", "bitop:shl", "num", "bitop:shr", "num", "bitop:band", "num", "bitop:bor", "num"
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles registry matches like units and currencies", () => {
|
||||
const tokens = tokenize("100 USD to EUR", reg);
|
||||
const types = tokens.map(t => t.t);
|
||||
expect(types).toEqual(["num", "currency", "conv", "currency"]);
|
||||
});
|
||||
|
||||
it("disambiguates 'in' as inches", () => {
|
||||
const tokens = tokenize("5 ft 4 in in cm", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["num", "unit", "num", "unit", "conv", "unit"]);
|
||||
if (tokens[3].t === "unit") {
|
||||
expect(tokens[3].unit.id).toBe("inch");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not disambiguate 'in' when it is not inches", () => {
|
||||
const tokens = tokenize("in cm", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["conv", "unit"]);
|
||||
});
|
||||
|
||||
it("handles other registry constructs (funcs, aggs, etc)", () => {
|
||||
const tokens = tokenize("sum(1, 2) + sin(pi) %", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual([
|
||||
"agg", "lparen", "num", "junk", "num", "rparen", "op", "func", "lparen", "const", "rparen", "percent"
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles scale and repr", () => {
|
||||
const tokens = tokenize("1M in hex", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["num", "scale", "conv", "repr"]);
|
||||
});
|
||||
|
||||
it("handles pctop and date words", () => {
|
||||
const tokens = tokenize("5% of 100 today", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["num", "percent", "pctop", "num", "date"]);
|
||||
});
|
||||
|
||||
it("handles bang, semicolon, unknown", () => {
|
||||
const tokens = tokenize("! ; ?", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["bang", "semicolon", "unknown"]);
|
||||
});
|
||||
|
||||
it("handles special tokens", () => {
|
||||
const tokens = tokenize("unix date", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["special", "special"]);
|
||||
});
|
||||
|
||||
it("handles assign", () => {
|
||||
const tokens = tokenize("x = 5", reg);
|
||||
expect(tokens.map(t => t.t)).toEqual(["word", "assign", "num"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { qty, pct, Decimal } from "./types";
|
||||
|
||||
describe("types", () => {
|
||||
describe("qty", () => {
|
||||
it("creates a quantity with default arguments", () => {
|
||||
const result = qty(42);
|
||||
expect(result.kind).toBe("quantity");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toNumber()).toBe(42);
|
||||
expect(result.unit).toBeNull();
|
||||
expect(result.repr).toBe("decimal");
|
||||
});
|
||||
|
||||
it("creates a quantity with all arguments provided", () => {
|
||||
const mockUnit = { id: "meter", dimension: "length", ratio: new Decimal(1), format: "m" } as any;
|
||||
const result = qty("3.14", mockUnit, "hex");
|
||||
expect(result.kind).toBe("quantity");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toString()).toBe("3.14");
|
||||
expect(result.unit).toBe(mockUnit);
|
||||
expect(result.repr).toBe("hex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pct", () => {
|
||||
it("creates a percent value from number", () => {
|
||||
const result = pct(25);
|
||||
expect(result.kind).toBe("percent");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toNumber()).toBe(25);
|
||||
});
|
||||
|
||||
it("creates a percent value from string", () => {
|
||||
const result = pct("12.5");
|
||||
expect(result.kind).toBe("percent");
|
||||
expect(result.value).toBeInstanceOf(Decimal);
|
||||
expect(result.value.toString()).toBe("12.5");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,11 +4,6 @@ Decimal.set({ precision: 40, toExpNeg: -9, toExpPos: 21 });
|
|||
|
||||
export { Decimal };
|
||||
|
||||
/** 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"
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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": "Тонганская паанга,Паанга,Паангах",
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("./vocab-data", () => ({
|
||||
EN: {
|
||||
"TestCategory": {
|
||||
"test_key.variants": "a, b, c ,a, ",
|
||||
"test_key.format": "en_fmt",
|
||||
"test_key.suffix1": "val1",
|
||||
"test_key2.variants": "x,y",
|
||||
"test_key2.suffix1": "val2",
|
||||
"same.key": "duplicate",
|
||||
}
|
||||
},
|
||||
RU: {
|
||||
"TestCategory": {
|
||||
"test_key.variants": "d, b , e",
|
||||
"test_key.format": "ru_fmt",
|
||||
"test_key3.variants": "z",
|
||||
"test_key3.suffix2": "val3",
|
||||
"same.key": "duplicate",
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { variants, entry, entryEn, entriesAll, idsOf, idsWithSuffix } from "./vocab";
|
||||
|
||||
describe("vocab", () => {
|
||||
describe("variants", () => {
|
||||
it("returns comma-split, trimmed, and deduped variants across locales", () => {
|
||||
// EN has "a", "b", "c", "a", "" (empty string trimmed out)
|
||||
// RU has "d", "b", "e"
|
||||
const res = variants("TestCategory", "test_key.variants");
|
||||
expect(res).toEqual(["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown category or key", () => {
|
||||
expect(variants("UnknownCategory", "test_key.variants")).toEqual([]);
|
||||
expect(variants("TestCategory", "unknown_key.variants")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry", () => {
|
||||
it("returns the first locale's value", () => {
|
||||
expect(entry("TestCategory", "test_key.format")).toBe("en_fmt");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown category or key", () => {
|
||||
expect(entry("UnknownCategory", "test_key.format")).toBeUndefined();
|
||||
expect(entry("TestCategory", "unknown_key")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("entryEn", () => {
|
||||
it("returns only the English value", () => {
|
||||
expect(entryEn("TestCategory", "test_key.format")).toBe("en_fmt");
|
||||
});
|
||||
|
||||
it("returns undefined if only in RU", () => {
|
||||
expect(entryEn("TestCategory", "test_key3.variants")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("entriesAll", () => {
|
||||
it("returns all unique values across locales", () => {
|
||||
expect(entriesAll("TestCategory", "test_key.format")).toEqual(["en_fmt", "ru_fmt"]);
|
||||
});
|
||||
|
||||
it("deduplicates values if they are the same in multiple locales", () => {
|
||||
expect(entriesAll("TestCategory", "same.key")).toEqual(["duplicate"]);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown category or key", () => {
|
||||
expect(entriesAll("UnknownCategory", "test_key.format")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idsOf", () => {
|
||||
it("returns all base keys ending in .variants across locales", () => {
|
||||
const res = idsOf("TestCategory");
|
||||
expect(res.sort()).toEqual(["test_key", "test_key2", "test_key3"].sort());
|
||||
});
|
||||
|
||||
it("returns empty array for unknown category", () => {
|
||||
expect(idsOf("UnknownCategory")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idsWithSuffix", () => {
|
||||
it("returns all keys ending with the given suffix across locales", () => {
|
||||
const res1 = idsWithSuffix("TestCategory", ".suffix1");
|
||||
expect(res1.sort()).toEqual(["test_key", "test_key2"].sort());
|
||||
|
||||
const res2 = idsWithSuffix("TestCategory", ".suffix2");
|
||||
expect(res2).toEqual(["test_key3"]);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown category or suffix", () => {
|
||||
expect(idsWithSuffix("UnknownCategory", ".suffix1")).toEqual([]);
|
||||
expect(idsWithSuffix("TestCategory", ".unknown_suffix")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
import { logger } from "./logger";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { makeApi, runExtensions } from "./extensions";
|
||||
import { SumEngine } from "./engine";
|
||||
|
||||
describe("extensions", () => {
|
||||
let mockEngine: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEngine = {
|
||||
setVariable: vi.fn(),
|
||||
addUnit: vi.fn(),
|
||||
addFunction: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("makeApi", () => {
|
||||
it("delegates setVariable to engine", () => {
|
||||
const api = makeApi(mockEngine as SumEngine);
|
||||
api.setVariable("myVar", 42);
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("myVar", 42);
|
||||
});
|
||||
|
||||
it("delegates addUnit to engine", () => {
|
||||
const api = makeApi(mockEngine as SumEngine);
|
||||
const spec = { id: "testUnit", phrases: "tests", baseUnitId: "USD", ratio: 1 };
|
||||
api.addUnit(spec);
|
||||
expect(mockEngine.addUnit).toHaveBeenCalledWith(spec);
|
||||
});
|
||||
|
||||
it("delegates addFunction to engine", () => {
|
||||
const api = makeApi(mockEngine as SumEngine);
|
||||
const spec = { id: "testFunc", phrases: "test" };
|
||||
const fn = () => 42;
|
||||
api.addFunction(spec, fn);
|
||||
expect(mockEngine.addFunction).toHaveBeenCalledWith(spec, fn);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runExtensions", () => {
|
||||
let consoleErrorSpy: any;
|
||||
let consoleWarnSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("quickjs-emscripten-core");
|
||||
});
|
||||
|
||||
it("runs scripts successfully using numi api", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
||||
{ name: "script2", code: "numi.addUnit({ id: 'unit1', phrases: 'u', baseUnitId: 'USD', ratio: 1 });" },
|
||||
]);
|
||||
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
||||
expect(mockEngine.addUnit).toHaveBeenCalledWith({
|
||||
id: "unit1",
|
||||
phrases: "u",
|
||||
baseUnitId: "USD",
|
||||
ratio: 1,
|
||||
});
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows errors and logs to console.error", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [{ name: "script1", code: "throw new Error('test error');" }]);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("extension script1 failed:", expect.any(Error));
|
||||
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("test error");
|
||||
});
|
||||
|
||||
it("continues running other scripts if one fails", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{ name: "script1", code: "numi.setVariable('var1', 10);" },
|
||||
{ name: "script2", code: "throw new Error('boom');" },
|
||||
{ name: "script3", code: "numi.setVariable('var3', 30);" },
|
||||
]);
|
||||
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var1", 10);
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("var3", 30);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes unit-carrying values through setVariable", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{ name: "s", code: "numi.setVariable('rent', { double: 1200, unitId: 'USD' });" },
|
||||
]);
|
||||
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("rent", { double: 1200, unitId: "USD" });
|
||||
});
|
||||
|
||||
it("rejects malformed api arguments as a script error", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [{ name: "bad", code: "numi.setVariable('x', 'nope');" }]);
|
||||
|
||||
expect(mockEngine.setVariable).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy.mock.calls[0][1].message).toContain("numi.setVariable");
|
||||
});
|
||||
|
||||
it("registers a function callable with one array of values", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{
|
||||
name: "hyp",
|
||||
code: "numi.addFunction({ id: 'hyp', phrases: 'hyp' }, function (values) { return { double: Math.hypot(values[0].double, values[1].double) }; });",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockEngine.addFunction).toHaveBeenCalledWith({ id: "hyp", phrases: "hyp" }, expect.any(Function));
|
||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
||||
expect(fn([{ double: 3 }, { double: 4 }])).toEqual({ double: 5 });
|
||||
expect(fn([{ double: 6 }, { double: 8 }])).toEqual({ double: 10 });
|
||||
});
|
||||
|
||||
it("marshals unitId into the sandbox and back out", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{
|
||||
name: "echo",
|
||||
code: "numi.addFunction({ id: 'echo', phrases: 'echo' }, function (v) { return { double: v[0].double * 2, unitId: v[0].unitId }; });",
|
||||
},
|
||||
]);
|
||||
|
||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
||||
expect(fn([{ double: 5, unitId: "meter" }])).toEqual({ double: 10, unitId: "meter" });
|
||||
});
|
||||
|
||||
it("has no host globals inside the sandbox", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{ name: "probe", code: "if (typeof window !== 'undefined' || typeof fetch !== 'undefined' || typeof console !== 'undefined') throw new Error('leak');" },
|
||||
]);
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts an infinite loop at load time", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{ name: "spin", code: "while (true) {}" },
|
||||
{ name: "after", code: "numi.setVariable('ok', 1);" },
|
||||
]);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toBe("extension spin failed:");
|
||||
// the runtime survives the interrupt and keeps loading scripts
|
||||
expect(mockEngine.setVariable).toHaveBeenCalledWith("ok", 1);
|
||||
});
|
||||
|
||||
it("interrupts a hung function on every call, not just the first", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{
|
||||
name: "spin",
|
||||
code: "numi.addFunction({ id: 'spin', phrases: 'spin' }, function () { while (true) {} });",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
code: "numi.addFunction({ id: 'good', phrases: 'good' }, function (v) { return v[0].double + 1; });",
|
||||
},
|
||||
]);
|
||||
|
||||
const spin = mockEngine.addFunction.mock.calls[0][1];
|
||||
const good = mockEngine.addFunction.mock.calls[1][1];
|
||||
|
||||
expect(() => spin([])).toThrow(/spin/);
|
||||
// deadline is re-armed per call: a second call still times out rather than
|
||||
// failing instantly, and an unrelated function still works afterwards
|
||||
expect(() => spin([])).toThrow(/spin/);
|
||||
expect(good([{ double: 1 }])).toBe(2);
|
||||
});
|
||||
|
||||
it("surfaces a bad return value as a function error", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, [
|
||||
{ name: "bad", code: "numi.addFunction({ id: 'bad', phrases: 'bad' }, function () { return 'nope'; });" },
|
||||
]);
|
||||
|
||||
const fn = mockEngine.addFunction.mock.calls[0][1];
|
||||
expect(() => fn([])).toThrow(/bad/);
|
||||
});
|
||||
|
||||
it("skips extensions when the sandbox fails to load", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("quickjs-emscripten-core", () => ({
|
||||
newQuickJSWASMModuleFromVariant: () => Promise.reject(new Error("no WebAssembly")),
|
||||
}));
|
||||
const { runExtensions: run } = await import("./extensions");
|
||||
const { logger } = await import("./logger");
|
||||
consoleWarnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
|
||||
await run(mockEngine as SumEngine, [{ name: "s", code: "numi.setVariable('x', 1);" }]);
|
||||
|
||||
expect(mockEngine.setVariable).not.toHaveBeenCalled();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"extensions disabled: QuickJS sandbox unavailable:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not load the sandbox when there are no scripts", async () => {
|
||||
await runExtensions(mockEngine as SumEngine, []);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +1,11 @@
|
|||
import { logger } from "./logger";
|
||||
// JS extensions runtime: numi.setVariable, 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 releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
|
||||
// JS extensions runtime: numi.setVariable,
|
||||
// numi.addUnit, numi.addFunction. Scripts share the engine's JS runtime.
|
||||
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
||||
|
||||
interface ExtensionApi {
|
||||
export 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 {
|
||||
|
|
@ -26,209 +16,14 @@ export function makeApi(engine: SumEngine): ExtensionApi {
|
|||
};
|
||||
}
|
||||
|
||||
// 32 MB is orders of magnitude more than a sheet helper needs, and small enough
|
||||
// that a runaway allocation fails inside the sandbox instead of the host heap.
|
||||
const MEMORY_LIMIT = 32 * 1024 * 1024;
|
||||
// 512 KB — unbounded recursion hits this and throws in the sandbox, never on the JS stack.
|
||||
const STACK_SIZE = 512 * 1024;
|
||||
// One-off cost at startup, so it can be generous.
|
||||
const LOAD_TIMEOUT_MS = 1000;
|
||||
// Registered functions are called synchronously from evaluateDocument, i.e. on
|
||||
// every keystroke for every line that uses them — this budget must stay small.
|
||||
const CALL_TIMEOUT_MS = 50;
|
||||
|
||||
const now = () =>
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
|
||||
function toExtValue(v: unknown, what: string): ExtensionValue | number {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as { double?: unknown; unitId?: unknown };
|
||||
if (typeof o.double === "number" && Number.isFinite(o.double)) {
|
||||
return typeof o.unitId === "string"
|
||||
? { double: o.double, unitId: o.unitId }
|
||||
: { double: o.double };
|
||||
}
|
||||
}
|
||||
throw new Error(`${what}: expected a number or { double, unitId? }`);
|
||||
}
|
||||
|
||||
function message(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
typeof (e as { message?: unknown }).message === "string"
|
||||
) {
|
||||
return (e as { message: string }).message;
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a
|
||||
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure.
|
||||
// Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app.
|
||||
export async function runExtensions(
|
||||
engine: SumEngine,
|
||||
scripts: { name: string; code: string }[],
|
||||
): Promise<void> {
|
||||
if (scripts.length === 0) return;
|
||||
try {
|
||||
await load(engine, scripts);
|
||||
} catch (e) {
|
||||
logger.warn("extensions disabled: QuickJS sandbox unavailable:", e);
|
||||
}
|
||||
}
|
||||
|
||||
type RunWithTimeout = <T>(timeoutMs: number, fn: () => T) => T;
|
||||
|
||||
function callGuest(
|
||||
vm: QuickJSContext,
|
||||
id: string,
|
||||
fn: QuickJSHandle,
|
||||
values: ExtensionValue[],
|
||||
runWithTimeout: RunWithTimeout,
|
||||
): ExtensionValue | number {
|
||||
// extension-sample.js convention: the callback takes one array of values.
|
||||
const arr = vm.newArray();
|
||||
try {
|
||||
values.forEach((v, i) => {
|
||||
const o = vm.newObject();
|
||||
const d = vm.newNumber(v.double);
|
||||
vm.setProp(o, "double", d);
|
||||
d.dispose();
|
||||
if (v.unitId) {
|
||||
const u = vm.newString(v.unitId);
|
||||
vm.setProp(o, "unitId", u);
|
||||
u.dispose();
|
||||
}
|
||||
vm.setProp(arr, i, o);
|
||||
o.dispose();
|
||||
});
|
||||
const res = runWithTimeout(CALL_TIMEOUT_MS, () =>
|
||||
vm.callFunction(fn, vm.undefined, [arr]),
|
||||
);
|
||||
if (res.error) {
|
||||
const err = vm.dump(res.error);
|
||||
res.error.dispose();
|
||||
throw new Error(`${id}: ${message(err)}`);
|
||||
}
|
||||
const out = vm.dump(res.value);
|
||||
res.value.dispose();
|
||||
return toExtValue(out, id);
|
||||
} finally {
|
||||
arr.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function exposeApi(
|
||||
vm: QuickJSContext,
|
||||
api: ExtensionApi,
|
||||
runWithTimeout: RunWithTimeout,
|
||||
) {
|
||||
const numi = vm.newObject();
|
||||
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
|
||||
const fn = vm.newFunction(name, (...args) => {
|
||||
try {
|
||||
impl(...args);
|
||||
} catch (e) {
|
||||
return { error: vm.newError(`numi.${name}: ${message(e)}`) };
|
||||
}
|
||||
});
|
||||
vm.setProp(numi, name, fn);
|
||||
fn.dispose();
|
||||
};
|
||||
|
||||
define("setVariable", (nameHandle, valueHandle) => {
|
||||
const name = vm.dump(nameHandle);
|
||||
if (typeof name !== "string" || !name)
|
||||
throw new Error("expects a variable name");
|
||||
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
|
||||
});
|
||||
|
||||
define("addUnit", (specHandle) => {
|
||||
const spec = vm.dump(specHandle);
|
||||
if (
|
||||
!spec ||
|
||||
typeof spec.id !== "string" ||
|
||||
typeof spec.baseUnitId !== "string" ||
|
||||
typeof spec.ratio !== "number"
|
||||
) {
|
||||
throw new Error("expects { id, phrases, baseUnitId, ratio }");
|
||||
}
|
||||
api.addUnit({
|
||||
id: spec.id,
|
||||
phrases: typeof spec.phrases === "string" ? spec.phrases : spec.id,
|
||||
baseUnitId: spec.baseUnitId,
|
||||
...(typeof spec.format === "string" ? { format: spec.format } : {}),
|
||||
ratio: spec.ratio,
|
||||
});
|
||||
});
|
||||
|
||||
define("addFunction", (specHandle, fnHandle) => {
|
||||
const spec = vm.dump(specHandle);
|
||||
if (!spec || typeof spec.id !== "string")
|
||||
throw new Error("expects { id, phrases }");
|
||||
if (!fnHandle || vm.typeof(fnHandle) !== "function")
|
||||
throw new Error("expects a function");
|
||||
// outlives this callback, so it needs its own reference; the context is never disposed
|
||||
const guestFn = fnHandle.dup();
|
||||
const id = spec.id;
|
||||
api.addFunction(
|
||||
{ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id },
|
||||
(values) => callGuest(vm, id, guestFn, values, runWithTimeout),
|
||||
);
|
||||
});
|
||||
|
||||
vm.setProp(vm.global, "numi", numi);
|
||||
numi.dispose();
|
||||
}
|
||||
|
||||
function evaluateScripts(
|
||||
vm: QuickJSContext,
|
||||
scripts: { name: string; code: string }[],
|
||||
runWithTimeout: RunWithTimeout,
|
||||
) {
|
||||
export function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): void {
|
||||
const numi = makeApi(engine);
|
||||
for (const s of scripts) {
|
||||
try {
|
||||
const res = runWithTimeout(LOAD_TIMEOUT_MS, () =>
|
||||
vm.evalCode(s.code, s.name),
|
||||
);
|
||||
if (res.error) {
|
||||
const err = vm.dump(res.error);
|
||||
res.error.dispose();
|
||||
throw new Error(message(err));
|
||||
}
|
||||
res.value.dispose();
|
||||
const fn = new Function("numi", s.code);
|
||||
fn(numi);
|
||||
} catch (e) {
|
||||
logger.error(`extension ${s.name} failed:`, e);
|
||||
console.error(`extension ${s.name} failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load(
|
||||
engine: SumEngine,
|
||||
scripts: { name: string; code: string }[],
|
||||
): Promise<void> {
|
||||
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
|
||||
const vm: QuickJSContext = mod.newContext();
|
||||
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
|
||||
vm.runtime.setMaxStackSize(STACK_SIZE);
|
||||
|
||||
// Armed before every entry into the sandbox and disarmed after, so a hung script
|
||||
// at load time and a hung function call on keystroke N are both bounded.
|
||||
let deadline = Infinity;
|
||||
vm.runtime.setInterruptHandler(() => now() > deadline);
|
||||
|
||||
const runWithTimeout: RunWithTimeout = (timeoutMs, fn) => {
|
||||
deadline = now() + timeoutMs;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
deadline = Infinity;
|
||||
}
|
||||
};
|
||||
|
||||
exposeApi(vm, makeApi(engine), runWithTimeout);
|
||||
evaluateScripts(vm, scripts, runWithTimeout);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { setLang, t, detectLang } from "./i18n";
|
||||
|
||||
describe("i18n", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
const mockElements = [
|
||||
{ dataset: { i18n: "settings" }, textContent: "" },
|
||||
{ dataset: { i18n: "theme" }, textContent: "" }
|
||||
];
|
||||
|
||||
vi.stubGlobal("document", {
|
||||
documentElement: { lang: "" },
|
||||
querySelectorAll: vi.fn().mockReturnValue(mockElements)
|
||||
});
|
||||
vi.stubGlobal("navigator", { language: "en-US" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sets language and updates document lang", () => {
|
||||
setLang("ru");
|
||||
expect(t("settings")).toBe("Настройки");
|
||||
expect(document.documentElement.lang).toBe("ru");
|
||||
|
||||
// Check if querySelectorAll was called and elements were updated
|
||||
const elements = document.querySelectorAll("[data-i18n]");
|
||||
expect(elements[0].textContent).toBe("Настройки");
|
||||
expect(elements[1].textContent).toBe("Тема");
|
||||
|
||||
setLang("en");
|
||||
expect(t("settings")).toBe("Settings");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
it("falls back to en for unknown languages", () => {
|
||||
setLang("fr");
|
||||
expect(t("settings")).toBe("Settings");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
it("falls back to en dictionary for missing keys in ru", () => {
|
||||
// If a key exists in en but not ru, it should fallback to en
|
||||
// we don't have one right now, but we can test missing keys in both
|
||||
expect(t("unknown_key")).toBe("unknown_key");
|
||||
});
|
||||
|
||||
it("detects language from navigator", () => {
|
||||
vi.stubGlobal("navigator", { language: "ru-RU" });
|
||||
expect(detectLang()).toBe("ru");
|
||||
|
||||
vi.stubGlobal("navigator", { language: "en-US" });
|
||||
expect(detectLang()).toBe("en");
|
||||
|
||||
vi.stubGlobal("navigator", { language: undefined });
|
||||
expect(detectLang()).toBe("en");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { logger } from './logger';
|
||||
|
||||
describe('logger', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call console.info with correct arguments', () => {
|
||||
logger.info('test info', { key: 'value' });
|
||||
expect(console.info).toHaveBeenCalledWith('test info', { key: 'value' });
|
||||
});
|
||||
|
||||
it('should call console.warn with correct arguments', () => {
|
||||
logger.warn('test warn', 42);
|
||||
expect(console.warn).toHaveBeenCalledWith('test warn', 42);
|
||||
});
|
||||
|
||||
it('should call console.error with correct arguments', () => {
|
||||
const error = new Error('test error');
|
||||
logger.error('test error message', error);
|
||||
expect(console.error).toHaveBeenCalledWith('test error message', error);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export const logger = {
|
||||
info: (...args: any[]) => console.info(...args),
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
};
|
||||
127
src/main.test.ts
127
src/main.test.ts
|
|
@ -1,127 +0,0 @@
|
|||
import { test, vi, expect } from "vitest";
|
||||
|
||||
vi.mock("./ui/editor", () => ({
|
||||
SumEditor: vi.fn().mockImplementation(() => ({
|
||||
goToLine: vi.fn(),
|
||||
getSheetWithResults: vi.fn(() => "test-results"),
|
||||
getText: vi.fn(() => "test"),
|
||||
focus: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: vi.fn(() => ({
|
||||
minimize: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./storage", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual as object,
|
||||
isTauri: vi.fn(() => false),
|
||||
loadSettings: vi.fn(() => ({ language: "en", autostart: false, alwaysOnTop: false, autoUpdateEnabled: false, sidebarVisible: false, hotkey: "CmdOrCtrl+Space" })),
|
||||
loadAppData: vi.fn(() => ({ docs: [{id: "1", title: "Test"}], contents: {}, activeId: "1" })),
|
||||
saveSettings: vi.fn(),
|
||||
saveAppData: vi.fn(),
|
||||
getLaunchFile: vi.fn(() => null),
|
||||
flushAppData: vi.fn(),
|
||||
onAppQuit: vi.fn((_cb: any) => {}),
|
||||
onOpenFile: vi.fn(),
|
||||
onFileDrop: vi.fn(),
|
||||
registerHotkey: vi.fn(() => Promise.resolve(true)),
|
||||
applyAutostart: vi.fn(),
|
||||
applyAlwaysOnTop: vi.fn(),
|
||||
fetchRates: vi.fn(),
|
||||
fetchMarketData: vi.fn(),
|
||||
loadExtensionScripts: vi.fn(() => Promise.resolve([])),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./ui/search", () => ({
|
||||
initSearch: vi.fn(() => ({ open: vi.fn() }))
|
||||
}));
|
||||
|
||||
vi.mock("./updater", () => ({
|
||||
checkForUpdate: vi.fn(() => Promise.resolve(null))
|
||||
}));
|
||||
|
||||
vi.mock("./extensions", () => ({
|
||||
runExtensions: vi.fn(() => Promise.resolve())
|
||||
}));
|
||||
|
||||
test("boot application", async () => {
|
||||
const createMockElement = () => ({
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
classList: { add: vi.fn(), remove: vi.fn(), toggle: vi.fn(), contains: vi.fn() },
|
||||
style: { setProperty: vi.fn() },
|
||||
dataset: {},
|
||||
value: "",
|
||||
appendChild: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
getBoundingClientRect: vi.fn(() => ({ right: 100, width: 100 })),
|
||||
closest: vi.fn(),
|
||||
blur: vi.fn(),
|
||||
replaceChildren: vi.fn(),
|
||||
});
|
||||
|
||||
const mock$ = vi.fn(createMockElement);
|
||||
|
||||
vi.stubGlobal("document", {
|
||||
querySelector: mock$,
|
||||
querySelectorAll: vi.fn(() => []), // For i18n
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
body: createMockElement(),
|
||||
documentElement: createMockElement(),
|
||||
createElement: vi.fn(createMockElement),
|
||||
createElementNS: vi.fn(createMockElement),
|
||||
createDocumentFragment: vi.fn(createMockElement),
|
||||
head: createMockElement(),
|
||||
});
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
performance: { now: () => 0 },
|
||||
print: vi.fn(),
|
||||
getSelection: vi.fn(() => null),
|
||||
setInterval: vi.fn(),
|
||||
setTimeout: vi.fn().mockImplementation((cb: Function) => cb()),
|
||||
dispatchEvent: vi.fn(),
|
||||
CustomEvent: vi.fn(),
|
||||
});
|
||||
|
||||
vi.stubGlobal("matchMedia", vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
matches: false,
|
||||
})));
|
||||
|
||||
vi.stubGlobal("navigator", {
|
||||
clipboard: { writeText: vi.fn() },
|
||||
language: "en",
|
||||
});
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
|
||||
// Import main and wait for boot to complete
|
||||
await import("./main");
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// As mock is hoisted, we can import actual mocked funcs
|
||||
const { loadSettings, loadAppData } = await import("./storage");
|
||||
expect(loadSettings).toHaveBeenCalled();
|
||||
expect(loadAppData).toHaveBeenCalled();
|
||||
});
|
||||
672
src/main.ts
672
src/main.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,419 +0,0 @@
|
|||
import { logger } from "./logger";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
});
|
||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('isTauri', () => {
|
||||
it('returns false when not in Tauri', () => {
|
||||
expect(isTauri()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when in Tauri', () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
expect(isTauri()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettings', () => {
|
||||
it('loads from localStorage when not in Tauri', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ precision: 5 }));
|
||||
const settings = await loadSettings();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.settings');
|
||||
expect(settings.precision).toBe(5);
|
||||
expect(settings.theme).toBe(defaultSettingsData.theme);
|
||||
});
|
||||
|
||||
it('loads via invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 }));
|
||||
const settings = await loadSettings();
|
||||
expect(invoke).toHaveBeenCalledWith('load_file', { name: 'settings.json' });
|
||||
expect(settings.precision).toBe(8);
|
||||
expect(settings.theme).toBe(defaultSettingsData.theme);
|
||||
});
|
||||
|
||||
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||
const settings = await loadSettings();
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
|
||||
it('falls back to defaultSettingsData if invoke throws', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
const settings = await loadSettings();
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
|
||||
it('falls back to defaultSettingsData if localStorage returns null', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
||||
const settings = await loadSettings();
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
|
||||
it('falls back to defaultSettingsData if JSON is invalid', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||
const settings = await loadSettings();
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
||||
expect(settings).toEqual(defaultSettingsData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSettings', () => {
|
||||
it('saves to localStorage when not in Tauri', async () => {
|
||||
const settings = { ...defaultSettingsData, precision: 10 };
|
||||
await saveSettings(settings);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.settings', JSON.stringify(settings, null, 2));
|
||||
});
|
||||
|
||||
it('saves via invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
const settings = { ...defaultSettingsData, precision: 12 };
|
||||
await saveSettings(settings);
|
||||
expect(invoke).toHaveBeenCalledWith('save_file', { name: 'settings.json', contents: JSON.stringify(settings, null, 2) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAppData', () => {
|
||||
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
||||
|
||||
it('loads from localStorage when not in Tauri', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockAppData));
|
||||
const data = await loadAppData('');
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.documents');
|
||||
expect(data).toEqual(mockAppData);
|
||||
});
|
||||
|
||||
it('loads via invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
||||
const data = await loadAppData('');
|
||||
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: null });
|
||||
expect(data).toEqual(mockAppData);
|
||||
});
|
||||
|
||||
it('loads via invoke when in Tauri with specific dataDir', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
||||
const data = await loadAppData('custom_dir');
|
||||
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: 'custom_dir' });
|
||||
expect(data).toEqual(mockAppData);
|
||||
});
|
||||
|
||||
it('returns null if localStorage throws', async () => {
|
||||
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||
const data = await loadAppData('');
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if invoke throws', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
const data = await loadAppData('');
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if localStorage returns null', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
||||
const data = await loadAppData('');
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if JSON is invalid', async () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
||||
const data = await loadAppData('');
|
||||
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveAppData & flushAppData', () => {
|
||||
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('debounces saves and flushes correctly when not in Tauri', async () => {
|
||||
setDataDir('');
|
||||
saveAppData(mockAppData);
|
||||
|
||||
// Should not save immediately
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
|
||||
// Fast forward past debounce time
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
|
||||
});
|
||||
|
||||
it('debounces saves and flushes correctly when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
|
||||
setDataDir('custom_dir');
|
||||
saveAppData(mockAppData);
|
||||
|
||||
// Should not save immediately
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
// Fast forward past debounce time
|
||||
vi.runAllTimers();
|
||||
|
||||
// storage.ts's invoke wrapper does a dynamic import() before calling
|
||||
// invoke(), which resolves over real event-loop ticks (module
|
||||
// transform/IO) even with fake timers active — poll with real timers
|
||||
// until it settles instead of guessing a fixed number of ticks.
|
||||
vi.useRealTimers();
|
||||
await vi.waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('save_documents', { dir: 'custom_dir', contents: JSON.stringify(mockAppData) });
|
||||
});
|
||||
});
|
||||
|
||||
it('flushAppData clears timer and saves immediately', async () => {
|
||||
setDataDir('');
|
||||
saveAppData(mockAppData);
|
||||
|
||||
await flushAppData();
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
|
||||
|
||||
// Advancing timers should not trigger another save
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushAppData does nothing if no pending data', async () => {
|
||||
await flushAppData();
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles localStorage throw gracefully in flushAppData', async () => {
|
||||
setDataDir('');
|
||||
saveAppData(mockAppData);
|
||||
vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
||||
|
||||
await flushAppData();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||
});
|
||||
|
||||
it('handles invoke throw gracefully in flushAppData', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
setDataDir('');
|
||||
saveAppData(mockAppData);
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
|
||||
await flushAppData();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup and utility functions', () => {
|
||||
describe('runBackups', () => {
|
||||
it('does nothing when not in Tauri', async () => {
|
||||
await runBackups('dir', 10);
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
await runBackups('dir', 10);
|
||||
expect(invoke).toHaveBeenCalledWith('run_backups', { dir: 'dir', retentionDays: 10 });
|
||||
});
|
||||
|
||||
it('handles invoke throw gracefully', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
await runBackups('dir', 10);
|
||||
expect(logger.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupDeletedSheet', () => {
|
||||
it('does nothing when not in Tauri', async () => {
|
||||
await backupDeletedSheet('dir', 'title', 'contents');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if contents are empty', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
await backupDeletedSheet('dir', 'title', ' ');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls invoke when in Tauri with valid contents', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
await backupDeletedSheet('dir', 'title', 'contents');
|
||||
expect(invoke).toHaveBeenCalledWith('backup_deleted_sheet', { dir: 'dir', title: 'title', contents: 'contents' });
|
||||
});
|
||||
|
||||
it('handles invoke throw gracefully', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
||||
await backupDeletedSheet('dir', 'title', 'contents');
|
||||
expect(logger.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('openBackupsFolder', () => {
|
||||
it('does nothing when not in Tauri', async () => {
|
||||
await openBackupsFolder('dir');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
await openBackupsFolder('dir');
|
||||
expect(invoke).toHaveBeenCalledWith('open_backups_folder', { dir: 'dir' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseFolder', () => {
|
||||
it('returns null when not in Tauri', async () => {
|
||||
expect(await chooseFolder()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns path when in Tauri and folder is chosen', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockResolvedValue('chosen_path');
|
||||
expect(await chooseFolder()).toBe('chosen_path');
|
||||
expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined);
|
||||
});
|
||||
|
||||
it('returns null when in Tauri and folder choice is cancelled', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockResolvedValue(null);
|
||||
expect(await chooseFolder()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataDirHasDocuments', () => {
|
||||
it('returns false when not in Tauri', async () => {
|
||||
expect(await dataDirHasDocuments('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('calls invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
vi.mocked(invoke).mockResolvedValue(true);
|
||||
expect(await dataDirHasDocuments('dir')).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith('data_dir_has_documents', { dir: 'dir' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateDataDir', () => {
|
||||
it('does nothing when not in Tauri', async () => {
|
||||
await migrateDataDir('old', 'new', 'move');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
await migrateDataDir('old', 'new', 'move');
|
||||
expect(invoke).toHaveBeenCalledWith('migrate_data_dir', { oldDir: 'old', newDir: 'new', strategy: 'move' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRates', () => {
|
||||
it('uses invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
const mockRates = { date: '2024-01-01', rates: { EUR: 0.85 }, fetchedAt: 12345 };
|
||||
vi.mocked(invoke).mockResolvedValue(mockRates);
|
||||
|
||||
const result = await fetchRates(true);
|
||||
expect(invoke).toHaveBeenCalledWith('fetch_rates', { force: true });
|
||||
expect(result).toEqual(mockRates);
|
||||
});
|
||||
|
||||
it('uses fetch when not in Tauri', async () => {
|
||||
const mockResponse = { result: 'success', time_last_update_utc: '2024-01-01', rates: { EUR: 0.85 } };
|
||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
||||
|
||||
const result = await fetchRates();
|
||||
expect(fetch).toHaveBeenCalledWith('https://open.er-api.com/v6/latest/USD');
|
||||
expect(result?.date).toBe('2024-01-01');
|
||||
expect(result?.rates).toEqual({ EUR: 0.85 });
|
||||
expect(result?.fetchedAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('returns null if fetch fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
const result = await fetchRates();
|
||||
expect(logger.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if API result is not success', async () => {
|
||||
const mockResponse = { result: 'error', 'error-type': 'unsupported-code' };
|
||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
||||
const result = await fetchRates();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchHistoricalRates', () => {
|
||||
it('uses invoke when in Tauri', async () => {
|
||||
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
||||
const mockRates = { EUR: 0.85 };
|
||||
vi.mocked(invoke).mockResolvedValue(mockRates);
|
||||
|
||||
const result = await fetchHistoricalRates('2024-01-01');
|
||||
expect(invoke).toHaveBeenCalledWith('fetch_historical_rates', { date: '2024-01-01' });
|
||||
expect(result).toEqual(mockRates);
|
||||
});
|
||||
|
||||
it('uses fetch when not in Tauri and formats rates correctly', async () => {
|
||||
const mockResponse = { rates: { EUR: 0.85, GBP: 0.75, JPY: -1 } }; // negative value should be ignored
|
||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
||||
|
||||
const result = await fetchHistoricalRates('2024-01-01');
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2024-01-01?from=USD');
|
||||
expect(result).toEqual({ USD: 1, EUR: 0.85, GBP: 0.75 }); // USD: 1 added, negative ignored
|
||||
});
|
||||
|
||||
it('returns null if fetch fails', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
const result = await fetchHistoricalRates('2024-01-01');
|
||||
expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if rates missing in response', async () => {
|
||||
const mockResponse = { amount: 1, base: 'USD', date: '2024-01-01' }; // missing rates
|
||||
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
||||
const result = await fetchHistoricalRates('2024-01-01');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { logger } from "./logger";
|
||||
// Storage: Tauri commands in the app, localStorage fallback for `vite dev`.
|
||||
|
||||
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,16 +227,16 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- image export
|
||||
|
||||
export async function writeImageFile(dataBase64: string): Promise<boolean> {
|
||||
if (!isTauri()) return false;
|
||||
return invoke("write_image_file", { dataBase64 });
|
||||
export async function writeImageFile(path: string, dataBase64: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
await invoke("write_image_file", { path, dataBase64 });
|
||||
}
|
||||
|
||||
// ---------- market data
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
124
src/ui/editor.ts
124
src/ui/editor.ts
|
|
@ -1,11 +1,10 @@
|
|||
// CodeMirror editor wired to the engine: evaluates on change, highlights
|
||||
// engine tokens, renders the results overlay, autocompletes phrases.
|
||||
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
|
||||
import { EditorView, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
|
||||
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
|
||||
import { 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;
|
||||
|
|
@ -66,17 +65,12 @@ function buildDecorations(state: EditorState): DecorationSet {
|
|||
|
||||
const highlightField = StateField.define<DecorationSet>({
|
||||
create: buildDecorations,
|
||||
update(value, tr) {
|
||||
const hasResults = tr.effects.some((e) => e.is(setResults));
|
||||
if (hasResults) return buildDecorations(tr.state);
|
||||
if (tr.docChanged) return value.map(tr.changes);
|
||||
return value;
|
||||
update(_value, tr) {
|
||||
return buildDecorations(tr.state);
|
||||
},
|
||||
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,67 +86,69 @@ 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();
|
||||
}
|
||||
|
||||
const setVarHighlight = StateEffect.define<DecorationSet>();
|
||||
|
||||
const varHighlightField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(setVarHighlight)) return e.value;
|
||||
}
|
||||
if (tr.docChanged) return value.map(tr.changes);
|
||||
return value;
|
||||
create: buildVarHighlight,
|
||||
update(_value, tr) {
|
||||
return buildVarHighlight(tr.state);
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
const varHighlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.schedule(view);
|
||||
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);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet) {
|
||||
this.schedule(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
schedule(view: EditorView) {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => {
|
||||
const decos = buildVarHighlight(view.state);
|
||||
view.dispatch({ effects: setVarHighlight.of(decos) });
|
||||
}, 150);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export class SumEditor {
|
||||
view: EditorView;
|
||||
|
|
@ -170,7 +166,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,16 +178,10 @@ 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) {
|
||||
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 };
|
||||
};
|
||||
|
||||
|
|
@ -208,7 +197,6 @@ export class SumEditor {
|
|||
resultsField,
|
||||
highlightField,
|
||||
varHighlightField,
|
||||
varHighlightPlugin,
|
||||
EditorView.lineWrapping,
|
||||
drawSelection({ cursorBlinkRate: 1000 }),
|
||||
// CM injects its base styles at runtime after app.css, so layout
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -58,9 +58,12 @@ export async function exportSheetImage(deps: ExportImageDeps): Promise<void> {
|
|||
} catch {
|
||||
// clipboard API failed — save to file
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }] });
|
||||
if (path) {
|
||||
const buf = await blob.arrayBuffer();
|
||||
const b64 = bytesToBase64(new Uint8Array(buf));
|
||||
if (await writeImageFile(b64)) {
|
||||
await writeImageFile(path, b64);
|
||||
toast(t("saved"));
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -53,42 +49,23 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
|
|||
const th = rq.threshold.value;
|
||||
const hits: SearchHit[] = [];
|
||||
for (const doc of docs) {
|
||||
const results = deps.workspace.getCachedResults(doc);
|
||||
let lines: string[] | undefined;
|
||||
const results = deps.workspace.evaluateSheet(doc.id, doc.text);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildSparkline } from "./sparkline";
|
||||
|
||||
describe("buildSparkline", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", {
|
||||
createElementNS: vi.fn((ns, tag) => {
|
||||
const attributes: Record<string, string> = {};
|
||||
const classes: string[] = [];
|
||||
const children: any[] = [];
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
return {
|
||||
tagName: tag,
|
||||
namespaceURI: ns,
|
||||
setAttribute: vi.fn((key, val) => {
|
||||
attributes[key] = val;
|
||||
}),
|
||||
getAttribute: vi.fn((key) => attributes[key]),
|
||||
classList: {
|
||||
add: vi.fn((cls) => {
|
||||
classes.push(cls);
|
||||
}),
|
||||
contains: vi.fn((cls) => classes.includes(cls)),
|
||||
},
|
||||
style,
|
||||
appendChild: vi.fn((child) => {
|
||||
children.push(child);
|
||||
}),
|
||||
_getAttributes: () => attributes,
|
||||
_getClasses: () => classes,
|
||||
_getChildren: () => children,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("creates a base SVG element with correct attributes and style", () => {
|
||||
const pts = [10, 20, 30];
|
||||
const svg = buildSparkline(pts, 150) as any;
|
||||
|
||||
expect(document.createElementNS).toHaveBeenCalledWith("http://www.w3.org/2000/svg", "svg");
|
||||
expect(svg.tagName).toBe("svg");
|
||||
expect(svg._getAttributes()).toMatchObject({
|
||||
width: "80",
|
||||
height: "20",
|
||||
viewBox: "0 0 80 20",
|
||||
});
|
||||
expect(svg._getClasses()).toContain("result-sparkline");
|
||||
expect(svg.style.top).toBe("150px");
|
||||
});
|
||||
|
||||
it("appends a straight line when all points are equal (min === max)", () => {
|
||||
const pts = [10, 10, 10];
|
||||
const svg = buildSparkline(pts, 0) as any;
|
||||
|
||||
const children = svg._getChildren();
|
||||
expect(children.length).toBe(1);
|
||||
|
||||
const line = children[0];
|
||||
expect(line.tagName).toBe("line");
|
||||
expect(line._getAttributes()).toMatchObject({
|
||||
x1: "2",
|
||||
y1: "10",
|
||||
x2: "78",
|
||||
y2: "10",
|
||||
stroke: "var(--result)",
|
||||
"stroke-width": "1.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends a polyline and a circle for varying points", () => {
|
||||
const pts = [10, 20, 30]; // min=10, max=30
|
||||
const svg = buildSparkline(pts, 0) as any;
|
||||
|
||||
const children = svg._getChildren();
|
||||
expect(children.length).toBe(2);
|
||||
|
||||
const polyline = children[0];
|
||||
expect(polyline.tagName).toBe("polyline");
|
||||
expect(polyline._getAttributes()).toMatchObject({
|
||||
fill: "none",
|
||||
stroke: "var(--result)",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
});
|
||||
|
||||
// Check points calculation
|
||||
const pointsStr = polyline._getAttributes().points;
|
||||
expect(typeof pointsStr).toBe("string");
|
||||
const points = pointsStr.split(" ");
|
||||
expect(points.length).toBe(3);
|
||||
|
||||
// First point should be at x=2 (pad), y=18 (H-pad because value is min)
|
||||
expect(points[0]).toBe("2.0,18.0");
|
||||
// Middle point should be at x=40 (pad + W/2), y=10 (pad + H/2 because value is mid)
|
||||
expect(points[1]).toBe("40.0,10.0");
|
||||
// Last point should be at x=78 (W-pad), y=2 (pad because value is max)
|
||||
expect(points[2]).toBe("78.0,2.0");
|
||||
|
||||
const circle = children[1];
|
||||
expect(circle.tagName).toBe("circle");
|
||||
expect(circle._getAttributes()).toMatchObject({
|
||||
cx: "78.0",
|
||||
cy: "2.0",
|
||||
r: "2",
|
||||
fill: "var(--result)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
export function buildSparkline(pts: number[], top: number): SVGSVGElement {
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const W = 80, H = 20, pad = 2;
|
||||
const svg = document.createElementNS(NS, "svg") as SVGSVGElement;
|
||||
svg.setAttribute("width", String(W));
|
||||
svg.setAttribute("height", String(H));
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
svg.classList.add("result-sparkline");
|
||||
svg.style.top = `${top}px`;
|
||||
const min = Math.min(...pts), max = Math.max(...pts);
|
||||
if (min === max) {
|
||||
const line = document.createElementNS(NS, "line");
|
||||
line.setAttribute("x1", String(pad));
|
||||
line.setAttribute("y1", String(H / 2));
|
||||
line.setAttribute("x2", String(W - pad));
|
||||
line.setAttribute("y2", String(H / 2));
|
||||
line.setAttribute("stroke", "var(--result)");
|
||||
line.setAttribute("stroke-width", "1.5");
|
||||
svg.appendChild(line);
|
||||
} else {
|
||||
const coords = pts.map((v, i) => {
|
||||
const x = pad + (i / (pts.length - 1)) * (W - 2 * pad);
|
||||
const y = pad + (1 - (v - min) / (max - min)) * (H - 2 * pad);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const poly = document.createElementNS(NS, "polyline");
|
||||
poly.setAttribute("points", coords);
|
||||
poly.setAttribute("fill", "none");
|
||||
poly.setAttribute("stroke", "var(--result)");
|
||||
poly.setAttribute("stroke-width", "1.5");
|
||||
poly.setAttribute("stroke-linecap", "round");
|
||||
poly.setAttribute("stroke-linejoin", "round");
|
||||
svg.appendChild(poly);
|
||||
const last = pts[pts.length - 1];
|
||||
const cx = (W - pad).toFixed(1);
|
||||
const cy = (pad + (1 - (last - min) / (max - min)) * (H - 2 * pad)).toFixed(1);
|
||||
const dot = document.createElementNS(NS, "circle");
|
||||
dot.setAttribute("cx", cx);
|
||||
dot.setAttribute("cy", cy);
|
||||
dot.setAttribute("r", "2");
|
||||
dot.setAttribute("fill", "var(--result)");
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { logger } from "./logger";
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { checkForUpdate } from './updater';
|
||||
import * as storage from './storage';
|
||||
import type { Update } from '@tauri-apps/plugin-updater';
|
||||
|
||||
vi.mock('./storage', () => ({
|
||||
isTauri: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/plugin-updater', () => ({
|
||||
check: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/app', () => ({
|
||||
getVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/plugin-process', () => ({
|
||||
relaunch: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('checkForUpdate', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(logger, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should return null if not in Tauri', async () => {
|
||||
vi.mocked(storage.isTauri).mockReturnValue(false);
|
||||
const result = await checkForUpdate();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return "error" if dynamic import or check throws', async () => {
|
||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
vi.mocked(check).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await checkForUpdate();
|
||||
expect(result).toBe('error');
|
||||
expect(logger.warn).toHaveBeenCalledWith('update check failed', expect.any(Error));
|
||||
});
|
||||
|
||||
it('should return null if no update is available', async () => {
|
||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const { getVersion } = await import('@tauri-apps/api/app');
|
||||
|
||||
vi.mocked(check).mockResolvedValue(null);
|
||||
vi.mocked(getVersion).mockResolvedValue('1.0.0');
|
||||
|
||||
const result = await checkForUpdate();
|
||||
expect(result).toBeNull();
|
||||
expect(logger.info).toHaveBeenCalledWith("no update: running 1.0.0, that's the latest published release");
|
||||
});
|
||||
|
||||
it('should return an update object if an update is available', async () => {
|
||||
vi.mocked(storage.isTauri).mockReturnValue(true);
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
|
||||
const mockUpdate = {
|
||||
version: '1.1.0',
|
||||
currentVersion: '1.0.0',
|
||||
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Update;
|
||||
vi.mocked(check).mockResolvedValue(mockUpdate);
|
||||
|
||||
const result = await checkForUpdate();
|
||||
expect(result).not.toBeNull();
|
||||
if (result !== 'error' && result !== null) {
|
||||
expect(result.version).toBe('1.1.0');
|
||||
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await result.install();
|
||||
expect(mockUpdate.downloadAndInstall).toHaveBeenCalled();
|
||||
expect(relaunch).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { logger } from "./logger";
|
||||
import { isTauri } from "./storage";
|
||||
|
||||
interface AvailableUpdate {
|
||||
export interface AvailableUpdate {
|
||||
version: string;
|
||||
install(): Promise<void>;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ interface SheetExports {
|
|||
vars: Map<string, Value>;
|
||||
total: Value | null;
|
||||
last: Value | null;
|
||||
results: LineResult[];
|
||||
}
|
||||
|
||||
const XREF_SCAN_RE = /@(?:\[([^\]]+)\]|([\p{L}_][\p{L}\d_]*))\./gu;
|
||||
|
|
@ -26,41 +25,29 @@ function escapeRegExp(s: string): string {
|
|||
|
||||
export class Workspace {
|
||||
private cache = new Map<string, SheetExports>();
|
||||
private resolving: Set<string> = new Set();
|
||||
private parsedRefsCache = new Map<string, { text: string; refs: string[] }>();
|
||||
private titleCache: Map<string, SheetSource> | null = null;
|
||||
private resolving: string[] = [];
|
||||
|
||||
constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {}
|
||||
|
||||
private doEvaluate(sheetId: string, text: string): LineResult[] {
|
||||
this.resolving.add(sheetId);
|
||||
/** Evaluate a sheet as the user's active/open document — refs resolve live. */
|
||||
evaluateSheet(sheetId: string, text: string): LineResult[] {
|
||||
this.resolving.push(sheetId);
|
||||
try {
|
||||
return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key));
|
||||
} finally {
|
||||
this.resolving.delete(sheetId);
|
||||
this.resolving.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate a sheet as the user's active/open document — refs resolve live. */
|
||||
evaluateSheet(sheetId: string, text: string): LineResult[] {
|
||||
return this.doEvaluate(sheetId, text);
|
||||
}
|
||||
|
||||
getCachedResults(sheet: SheetSource): LineResult[] {
|
||||
return this.exportsFor(sheet).results;
|
||||
}
|
||||
|
||||
/** Drop cached exports for a sheet and everything that transitively depends on it. */
|
||||
invalidate(sheetId: string): void {
|
||||
this.titleCache = null;
|
||||
const dirty = new Set<string>([sheetId]);
|
||||
const sheetsList = this.sheets();
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const s of sheetsList) {
|
||||
for (const s of this.sheets()) {
|
||||
if (dirty.has(s.id)) continue;
|
||||
for (const title of this.referencedTitles(s.text, s.id)) {
|
||||
for (const title of this.referencedTitles(s.text)) {
|
||||
const target = this.findSheetByTitle(title);
|
||||
if (target && dirty.has(target.id)) {
|
||||
dirty.add(s.id);
|
||||
|
|
@ -74,9 +61,7 @@ export class Workspace {
|
|||
}
|
||||
|
||||
invalidateAll(): void {
|
||||
this.titleCache = null;
|
||||
this.cache.clear();
|
||||
this.parsedRefsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,37 +83,21 @@ export class Workspace {
|
|||
return out;
|
||||
}
|
||||
|
||||
private referencedTitles(text: string, id?: string): string[] {
|
||||
if (id) {
|
||||
const cached = this.parsedRefsCache.get(id);
|
||||
if (cached && cached.text === text) return cached.refs;
|
||||
}
|
||||
private referencedTitles(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim());
|
||||
if (id) {
|
||||
this.parsedRefsCache.set(id, { text, refs: out });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private findSheetByTitle(title: string): SheetSource | undefined {
|
||||
const needle = title.trim().toLowerCase();
|
||||
if (this.titleCache === null) {
|
||||
this.titleCache = new Map();
|
||||
for (const s of this.sheets()) {
|
||||
const key = s.title.trim().toLowerCase();
|
||||
if (!this.titleCache.has(key)) {
|
||||
this.titleCache.set(key, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.titleCache.get(needle);
|
||||
return this.sheets().find((s) => s.title.trim().toLowerCase() === needle);
|
||||
}
|
||||
|
||||
private resolveXRef(sheetTitle: string, key: string): XRefResolution {
|
||||
const target = this.findSheetByTitle(sheetTitle);
|
||||
if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` };
|
||||
if (this.resolving.has(target.id)) {
|
||||
if (this.resolving.includes(target.id)) {
|
||||
return { ok: false, reason: "circular reference" };
|
||||
}
|
||||
const exports = this.exportsFor(target);
|
||||
|
|
@ -150,7 +119,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.push(sheet.id);
|
||||
let results: LineResult[];
|
||||
try {
|
||||
results = this.engine.evaluateDocument(sheet.text, (s, k) => this.resolveXRef(s, k));
|
||||
} finally {
|
||||
this.resolving.pop();
|
||||
}
|
||||
const vars = new Map<string, Value>();
|
||||
for (const r of results) {
|
||||
if (r.assign && r.value) vars.set(r.assign, r.value);
|
||||
|
|
@ -162,7 +137,7 @@ export class Workspace {
|
|||
break;
|
||||
}
|
||||
}
|
||||
const exports: SheetExports = { vars, total: this.engine.totalValueOf(results), last, results };
|
||||
const exports: SheetExports = { vars, total: this.engine.totalValueOf(results), last };
|
||||
// A cycle-interrupted pass produces incomplete/wrong exports (dropped
|
||||
// assignments, missing total) — don't let it poison the cache. Re-evaluate
|
||||
// fresh on every query until the cycle is actually broken by an edit.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue